Route-Level Prefetching
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:
| Primitive | When 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.
// 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.
redirect callback runs synchronously. Prefetch is fire-and-forget: do notawait the prefetch, or you will block the navigation transition.Implementation
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:
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:
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:
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
| Strategy | Behaviour | Use 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-first | Always 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:
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:
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:
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:
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
- QoraClient.prefetch: API reference for the core prefetch method
- Best Practices: Additional prefetch patterns (hover, focus, scroll-into-view)
- Dependent Queries: Chain queries that depend on prefetched data
- Cancellation: Cancel in-flight prefetches on rapid navigation
- Testing: Write deterministic tests for prefetch logic