Recipes

Route-Level Prefetching

[object Object]

Route-level prefetching ensures data is already cached by the time a user navigates to a new screen. This guide covers GoRouter redirect-based prefetching, AutoRoute resolver-based prefetching, error handling strategies, and cache-first vs network-first trade-offs.


Core API

Qora provides three prefetch primitives, listed from most declarative to most imperative:

PrimitiveWhen to use
QoraPrefetch<T> (widget)Wrapping a child that is already in the tree (e.g. hover, focus, scroll-into-view).
context.prefetch<T>() (extension)Imperative trigger from a gesture callback or route event.
client.prefetch<T>() (core)Any Dart context without a widget tree (e.g. route guards, resolvers).

All three are no-ops when fresh data already exists for the given queryKey. Prefetch runs once per key; repeat calls are safe and cheap.

lib/features/user_detail_screen.dart
// Widget-based: wrap a child that implies "likely to navigate"
MouseRegion(
  onEnter: (_) => setState(() => _prefetch = true),
  child: _prefetch
      ? QoraPrefetch<User>(
          queryKey: ['user', userId],
          fetcher: () => api.getUser(userId),
          child: UserListTile(userId: userId),
        )
      : UserListTile(userId: userId),
)

// Imperative: call from any callback
GestureDetector(
  onLongPress: () => context.prefetch<User>(
    key: ['user', userId],
    fetcher: () => api.getUser(userId),
  ),
  child: UserTile(userId),
)

GoRouter: Redirect-Based Prefetching

GoRouter supports redirect callbacks that run before a route is activated. Use them to prefetch data synchronously (fire-and-forget), then let the route guard proceed immediately. By the time the screen widget mounts, its QoraBuilder reads from cache with zero network wait.

The redirect callback runs synchronously. Prefetch is fire-and-forget: do notawait the prefetch, or you will block the navigation transition.

Implementation

lib/app_router.dart
import 'package:go_router/go_router.dart';
import 'package:qora_flutter/qora_flutter.dart';

final _client = QoraClient();

final appRouter = GoRouter(
  routes: [
    GoRoute(
      path: '/users',
      builder: (_, __) => const UserListScreen(),
      routes: [
        GoRoute(
          path: ':id',
          redirect: (context, state) {
            // Prefetch user detail while the list is still visible.
            // The redirect runs synchronously; navigation proceeds immediately.
            final userId = state.pathParameters['id']!;
            _client.prefetch<User>(
              key: ['user', userId],
              fetcher: () => api.getUser(userId),
            ).ignore(); // fire-and-forget
            return null; // proceed to the route
          },
          builder: (_, state) {
            final userId = state.pathParameters['id']!;
            return UserDetailScreen(userId: userId);
          },
        ),
      ],
    ),
  ],
);

With error handling

Prefetch failures should never block navigation. Wrap the fetcher in a try/catch and log errors for observability:

lib/app_router.dart
redirect: (context, state) {
  final userId = state.pathParameters['id']!;
  _client.prefetch<User>(
    key: ['user', userId],
    fetcher: () async {
      try {
        return await api.getUser(userId);
      } catch (e, s) {
        log('Prefetch failed for user $userId', error: e, stackTrace: s);
        rethrow; // still captured by Qora's Failure state
      }
    },
  ).ignore();
  return null;
},

AutoRoute: Resolver-Based Prefetching

AutoRoute uses RouteGuard or an onNavigation callback inside the router delegate. A resolver-based approach fires prefetch before the resolver returns the route. The route renders only after the prefetch is dispatched:

lib/app_router.dart
import 'package:auto_route/auto_route.dart';
import 'package:qora_flutter/qora_flutter.dart';

final _client = QoraClient();

@AutoRouterConfig()
class AppRouter extends RootStackRouter {
  @override
  List<AutoRoute> get routes => [
        AutoRoute(page: UserListRoute.page, path: '/users', children: [
          AutoRoute(
            page: UserDetailRoute.page,
            path: ':id',
            guards: [PrefetchGuard()],
          ),
        ]),
      ];
}

class PrefetchGuard extends AutoRouteGuard {
  @override
  Future<void> onNavigation(
    NavigationResolver resolver,
    StackRouter router,
  ) async {
    final userId = resolver.route.pathParams.getString('id');
    if (userId != null) {
      _client
          .prefetch<User>(
            key: ['user', userId],
            fetcher: () => api.getUser(userId),
          )
          .ignore();
    }
    // Never block: resolve immediately, data loads in background.
    resolver.next();
  }
}

With a timeout fallback

If you want to wait a short grace period (e.g. 200 ms) to give the prefetch a head start before resolving, but never block longer than that:

lib/guards/prefetch_guard.dart
class PrefetchGuard extends AutoRouteGuard {
  @override
  Future<void> onNavigation(
    NavigationResolver resolver,
    StackRouter router,
  ) async {
    final userId = resolver.route.pathParams.getString('id');
    if (userId != null) {
      final prefetch = _client.prefetch<User>(
        key: ['user', userId],
        fetcher: () => api.getUser(userId),
      );
      // Wait at most 200 ms for the prefetch to settle.
      try {
        await prefetch.timeout(const Duration(milliseconds: 200));
      } catch (_) {
        // Timeout: resolver still proceeds, data loads on-screen.
      }
    }
    resolver.next();
  }
}

Strategy: Cache-First vs Network-First

StrategyBehaviourUse when
Cache-first (default)Prefetch is a no-op if fresh data exists. First navigation after stale expiry triggers a fetch.Data freshness window is known and acceptable.
Network-firstAlways fetch on navigation, bypassing the cache staleness check.Data must be guaranteed fresh on every visit (e.g. financial dashboards).

Cache-first (default)

The client.prefetch<T>() method already implements cache-first: it checks the cache and returns immediately if fresh data exists. No additional configuration needed.

Network-first

Force a fetch by calling fetchQuery directly or by invalidating the key before prefetching:

lib/app_router.dart
redirect: (context, state) {
  final userId = state.pathParameters['id']!;
  // Always fetch, even if fresh data exists.
  _client.invalidate(['user', userId]);
  _client.fetchQuery<User>(
    key: ['user', userId],
    fetcher: () => api.getUser(userId),
  ).ignore();
  return null;
},

For a reusable pattern, set staleTime to Duration.zero in the query options so every QoraBuilder mount triggers a fresh fetch:

lib/features/user_detail_screen.dart
QoraBuilder<User>(
  queryKey: ['user', userId],
  fetcher: () => api.getUser(userId),
  options: const QoraOptions(staleTime: Duration.zero),
  builder: (context, state, _) { ... },
)

Using QoraPrefetch Inside a Route

If you cannot (or prefer not to) modify your routing layer, embed QoraPrefetch at the widget level. This is the simplest approach when the navigable child is already in the tree:

lib/features/user_list_screen.dart
ListView.builder(
  itemCount: users.length,
  itemBuilder: (context, index) {
    final user = users[index];
    return QoraPrefetch<User>(
      queryKey: ['user', user.id],
      fetcher: () => api.getUser(user.id),
      child: ListTile(
        title: Text(user.name),
        onTap: () => context.push('/users/${user.id}'),
      ),
    );
  },
)

Prefetch fires on mount (when the list item appears) and re-fires if queryKey changes. By the time the user taps, data is cached.


Testing Prefetch Logic

Use QoraClient directly in tests without any routing framework:

test/user_prefetch_test.dart
import 'package:qora/qora.dart';
import 'package:test/test.dart';

void main() {
  test('prefetch populates cache before screen mounts', () async {
    final client = QoraClient();

    await client.prefetch<User>(
      key: ['user', 1],
      fetcher: () async => User(id: 1, name: 'Alice'),
    );

    // Screen widget now mounts and reads from cache.
    final state = client.getQueryState<User>(['user', 1]);
    expect(state, isA<Success<User>>());
    expect((state as Success<User>).data.name, 'Alice');
  });
}

Next Steps

Copyright © 2026