Async patterns
Every async operation in a screen is one of four shapes, and each has a hook built for it. Picking the right one is the difference between two lines and a tangle of useState flags.
| Shape | Hook | Trigger |
|---|---|---|
| Read, one-shot | useAutoComputedState | Automatic, on keys change |
| Read, paginated | usePaginatedComputedState | First page automatic, then loadMore |
| Write / mutation | useSubmitState | Manual, on a user action |
| Stream, reactive | useMemoizedStream | Continuous |
This page covers the low-level read primitives - useFuture, useStream, and their memoized variants - and when to reach past them for useAutoComputedState. Writes are covered in Submit state, paginated reads in Pagination, and the everyday read workhorse in Computed state.
Reading a Future or Stream
useFuture and useStream mirror Flutter's FutureBuilder and StreamBuilder: they subscribe to a Future/Stream and expose its current value as an AsyncSnapshot. The catch is the same one those widgets have - a new instance means a new subscription. Building the Future inline rebuilds it on every render, so each build re-fetches.
Pair useFuture with useMemoized, which only rebuilds the Future when its keys change:
class ProductName extends HookWidget {
const ProductName({super.key, required this.service, required this.productId});
final ProductService service;
final String productId;
Widget build(BuildContext context) {
// useMemoized rebuilds the Future only when productId changes; without it a
// fresh Future would be created on every build and re-fetched each time.
final future = useMemoized(() => service.load(productId), [productId]);
final snapshot = useFuture(future);
return Text(snapshot.data?.name ?? 'Loading...');
}
}
This pairing is so common that useMemoizedFuture folds it into one call - it takes the factory and the keys directly:
class ProductNameMemoized extends HookWidget {
const ProductNameMemoized({super.key, required this.service, required this.productId});
final ProductService service;
final String productId;
Widget build(BuildContext context) {
// useMemoizedFuture folds the useMemoized + useFuture pair into one call.
final snapshot = useMemoizedFuture(() => service.load(productId), keys: [productId]);
return Text(snapshot.data?.name ?? 'Loading...');
}
}
The same set exists for streams: useStream, useMemoizedStream, and the …Data variants (useFutureData, useMemoizedStreamData) that return the value directly instead of an AsyncSnapshot when you only need .data.
class LiveLocation extends HookWidget {
const LiveLocation({super.key, required this.service});
final LocationService service;
Widget build(BuildContext context) {
// A long-lived stream from a service - no keys, so it is subscribed once.
final snapshot = useMemoizedStream(() => service.positions);
final position = snapshot.data;
return Text(position == null ? 'Locating...' : '${position.lat}, ${position.lng}');
}
}
useStream only ever exposes the latest value. If a stream emits several values between builds, the intermediate ones are dropped. When every event matters - an event bus, a one-shot signal you must act on - use useStreamSubscription instead, which runs a callback per event.
Don't ignore the error
A Future or Stream that throws puts the error in snapshot.error and stops there. Nothing surfaces it; the screen just sits on its loading state. Handle it explicitly with useAsyncSnapshotErrorHandler:
class ProductNameHandled extends HookWidget {
const ProductNameHandled({super.key, required this.service, required this.productId});
final ProductService service;
final String productId;
Widget build(BuildContext context) {
final snapshot = useMemoizedFuture(() => service.load(productId), keys: [productId]);
// Surface the error explicitly instead of letting it sit silently in the snapshot.
useAsyncSnapshotErrorHandler(
snapshot,
onError: (error, stackTrace) => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load: $error')),
),
);
return Text(snapshot.data?.name ?? 'Loading...');
}
}
Without an onError, the handler forwards the error to Zone.handleUncaughtError - the app-wide crash path (see Error handling). The …Data hooks (useFutureData, useMemoizedStreamData) already run this handler internally and accept an onError, so reaching for them is the easiest way to avoid a silently swallowed error.
Prefer useAutoComputedState for reads
useMemoizedFuture returns a raw AsyncSnapshot: a connectionState, a nullable data, an error. For loading a screen's data you almost always want more than that - an explicit "not loaded yet" state, a way to re-fetch on demand, a guard that holds the fetch until a prerequisite is ready. That is useAutoComputedState, the default read hook, covered in Computed state.
Reach for the raw useMemoizedFuture / useMemoizedStream only when you specifically want AsyncSnapshot semantics - bridging an existing FutureBuilder-style API, or a value you render directly with no loading or error UX of its own.
A Stream-backed value that drives global state usually derives its isInitialized from the snapshot: snapshot.connectionState == ConnectionState.active. That is the standard way an auth or notifications state reports "ready".
See also
- Computed state -
useAutoComputedState, the everyday read hook this page points you toward - Submit state - the write counterpart for mutations
- Error handling - where uncaught async errors land and how to make them retryable
- Common hooks -
useFuture/useStream/useMemoizedin the wider hook set - Hooks library - signatures for every hook on this page