Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 296 additions & 0 deletions docs/content/en/04.recipes/12.prefetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
---
title: Route-Level Prefetching
description: Preload query data before a screen mounts using GoRouter redirects, AutoRoute resolvers, or QoraPrefetch: eliminating loading spinners during navigation.
navigation:
icon: i-lucide-navigation
seo:
title: Route-Level Prefetching - Qora
description: Prefetch Qora query data before a screen mounts with GoRouter and AutoRoute patterns. Eliminate loading spinners during navigation with cache-first or network-first strategies.
---

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.

```dart [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.

:::tip
The `redirect` callback runs synchronously. Prefetch is fire-and-forget: do **not** `await` the prefetch, or you will block the navigation transition.
:::

### Implementation

```dart [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:

```dart [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:

```dart [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:

```dart [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

| 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:

```dart [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:

```dart [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:

```dart [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:

```dart [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

- [QoraClient.prefetch](../api-reference/qora-client.md): API reference for the core prefetch method
- [Best Practices](../flutter-integration/best-practices.md): Additional prefetch patterns (hover, focus, scroll-into-view)
- [Dependent Queries](./dependent-queries.md): Chain queries that depend on prefetched data
- [Cancellation](./cancel-token.md): Cancel in-flight prefetches on rapid navigation
- [Testing](./testing.md): Write deterministic tests for prefetch logic
Loading
Loading