With Riverpod
Riverpod Provider and NotifierProvider are effective for dependency injection and client state. FutureProvider handles async reads but does not provide SWR, retry/backoff, or offline pause and replay semantics by default.
Qora adds these server-state capabilities without replacing Riverpod. Riverpod remains focused on dependency graphs and client state.
What FutureProvider Misses
final userProvider = FutureProvider.family<User, int>((ref, id) async {
return UserApi.getUser(id);
});
final userAsync = ref.watch(userProvider(userId));
return userAsync.when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => ErrorWidget('$e'),
data: (user) => UserCard(user),
);
Limitations:
- Full-screen spinner on every refresh: old data is discarded, no stale-while-revalidate.
- No retry with backoff: a single failure is surfaced immediately.
- No background refetch on app focus or reconnect.
- No offline support: no
FetchStatus, and no pause/replay behavior on reconnect. AsyncValuehas nopreviousData: no graceful degradation path.
Hooks Path
If you are already using flutter_hooks (which Riverpod developers often are via hooks_riverpod), qora_hooks provides a useQuery hook that feels native:
dependencies:
qora_flutter: ^ 1.3.1
qora_hooks: ^ 1.3.1 # requires flutter_hooks
class UserScreen extends HookWidget {
const UserScreen({required this.userId, super.key});
final int userId;
@override
Widget build(BuildContext context) {
final state = useQuery<User>(
key: ['users', userId],
fetcher: () => UserApi.getUser(userId),
options: const QoraOptions(staleTime: Duration(minutes: 5)),
);
return switch (state) {
Success(:final data) => UserCard(data),
Loading() => const CircularProgressIndicator(),
Failure(:final error) => ErrorWidget('$error'),
Initial() => const SizedBox.shrink(),
};
}
}
useQuery returns QoraState<T>, the same sealed type used across Qora APIs. Stale-while-revalidate, retry, background refetch, and deduplication are built in.
Graceful Degradation with previousData
Loading and Failure carry previousData, which is the last successful value:
final state = useQuery<User>(
key: ['users', userId],
fetcher: () => UserApi.getUser(userId),
);
return switch (state) {
Success(:final data) => UserCard(data),
Loading(:final previousData) when previousData != null =>
Stack(children: [
UserCard(previousData),
const LoadingOverlay(),
]),
Loading() => const CircularProgressIndicator(),
Failure(:final error, :final previousData) when previousData != null =>
Column(children: [
UserCard(previousData),
ErrorBanner('$error'),
]),
Failure(:final error) => ErrorWidget('$error'),
Initial() => const SizedBox.shrink(),
};
Mutations with useMutation
class UpdateNameForm extends HookWidget {
const UpdateNameForm({required this.userId, super.key});
final int userId;
@override
Widget build(BuildContext context) {
final handle = useMutation<User, String>(
mutator: (name) => UserApi.updateUser(userId, name),
options: MutationOptions(
onSuccess: (data, _, __) async {
QoraScope.of(context).setQueryData(['users', userId], data);
QoraScope.of(context).invalidateWhere((k) => k.firstOrNull == 'users');
},
),
);
return ElevatedButton(
onPressed: handle.isPending ? null : () => handle.mutate(nameController.text),
child: handle.isPending
? const CircularProgressIndicator()
: const Text('Save'),
);
}
}
MutationHandle exposes: isIdle, isPending, isSuccess, isError, data, error, mutate(variables), mutateAsync(variables), reset().
Widget API Alternative
If you prefer to stay with StatefulWidget rather than HookWidget, QoraBuilder provides the same capabilities:
QoraBuilder<User>(
queryKey: ['users', userId],
fetcher: () => UserApi.getUser(userId),
options: const QoraOptions(staleTime: Duration(minutes: 5)),
builder: (context, state, fetchStatus) {
if (fetchStatus == FetchStatus.paused) {
return OfflineBanner(staleData: state.dataOrNull);
}
return switch (state) {
Success(:final data) => UserCard(data),
Loading() => const CircularProgressIndicator(),
Failure(:final error) => ErrorWidget('$error'),
Initial() => const SizedBox.shrink(),
};
},
)
The third builder argument fetchStatus exposes FetchStatus.paused in offline states. Use QoraBuilder when this signal is required by the UI.
Keeping Riverpod for DI and Client State
Riverpod's Provider and NotifierProvider are unmatched for dependency injection and client state. The two scopes compose cleanly:
final qoraClient = QoraClient(
config: const QoraClientConfig(debugMode: kDebugMode),
);
runApp(
ProviderScope(
overrides: [
userApiProvider.overrideWithValue(UserApi(baseUrl: env.apiUrl)),
],
child: QoraScope(
client: qoraClient,
lifecycleManager: FlutterLifecycleManager(qoraClient: qoraClient),
connectivityManager: FlutterConnectivityManager(),
child: const MyApp(),
),
),
);
Use Riverpod providers to inject UserApi, QoraClient, or repositories, then consume them from useQuery or QoraBuilder.
Feature Comparison
| Feature | FutureProvider | useQuery / QoraBuilder |
|---|---|---|
| SWR: serve stale, refetch in background | No | Yes |
previousData during refetch | No | Yes |
| Auto retry with backoff | No | Yes |
| Background refetch on focus / reconnect | No | Yes |
FetchStatus.paused (offline indicator) | No | Yes |
| Offline persistence | No | Yes |
| Infinite / paginated queries | No | Yes |
| Deduplication of in-flight requests | Yes | Yes |
Checklist
- Add
qora_flutterandqora_hookstopubspec.yaml - Nest
QoraScopeinsideProviderScope - Replace
FutureProvider.familywithuseQuery(hooks) orQoraBuilder(widgets) - Replace
AsyncNotifierdata-fetch methods withuseMutationorQoraMutationBuilder - Keep Riverpod
NotifierProviderfor client state, DI, and service graphs - Replace
ref.invalidate(provider)withclient.invalidate(key)orclient.invalidateWhere