Skip to main content

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.

ShapeHookTrigger
Read, one-shotuseAutoComputedStateAutomatic, on keys change
Read, paginatedusePaginatedComputedStateFirst page automatic, then loadMore
Write / mutationuseSubmitStateManual, on a user action
Stream, reactiveuseMemoizedStreamContinuous

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.

tip

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 / useMemoized in the wider hook set
  • Hooks library - signatures for every hook on this page