FutureBuilder alternative with computed state hooks
Loading data is the job most screens spend their state on, and the hand-rolled version is always the same three variables: a nullable value, an isLoading flag, and an error. They drift out of sync the moment a second load overlaps the first. useAutoComputedState is the one hook that owns all three.
It runs an async compute on the first build and again whenever its keys change, and it returns a MutableComputedState<T> you can also drive by hand. This is the default tool for any one-shot read: a screen's data, a list, a search result.
class ProductScreenView extends StatelessWidget {
const ProductScreenView({super.key, required this.service, required this.productId});
final ProductService service;
final String productId;
Widget build(BuildContext context) {
return _ProductBody(service: service, productId: productId);
}
}
class _ProductBody extends HookWidget {
const _ProductBody({required this.service, required this.productId});
final ProductService service;
final String productId;
Widget build(BuildContext context) {
// Runs on first build and again whenever productId changes.
final productState = useAutoComputedState(() => service.load(productId), keys: [productId]);
// .value is a sum type; never throws. Read the data with .valueOrNull.
final product = productState.valueOrNull;
return Text(product?.name ?? 'Loading...');
}
}
productState.value is a ComputedStateValue<T> - a sum type with four cases (notInitialized, inProgress, ready, failed) that never throws. Read the loaded data with .valueOrNull (null until ready), or pattern-match the value with .when(...) / .maybeWhen(...) when you care about the difference between "loading" and "failed".
The full signature and the on-demand sibling useComputedState (which runs nothing until you call refresh() yourself) are on the reference pages - useAutoComputedState and useComputedState. This guide is about using them.
Rendering the value
Hand-writing a valueOrNull == null ? loader : content check is fine for the simple case, but it loses the failed branch. The bundled wrapper widgets render all of inProgress / failed / ready for you:
class ProductWrapped extends HookWidget {
const ProductWrapped({super.key, required this.service, required this.productId});
final ProductService service;
final String productId;
Widget build(BuildContext context) {
final productState = useAutoComputedState(() => service.load(productId), keys: [productId]);
// The wrapper renders the inProgress / failed / ready branches for you.
return ComputedStateWrapper<Product>(
state: productState,
inProgressBuilder: (context) => const Center(child: CircularProgressIndicator()),
failedBuilder: (context) => const Center(child: Text('Something went wrong')),
builder: (context, product) => Text(product.name),
);
}
}
For a list, ComputedIterableWrapper adds a required emptyBuilder. To put a pull-to-refresh on top - calling refresh() for you - use RefreshableComputedStateWrapper:
class ProductRefreshable extends HookWidget {
const ProductRefreshable({super.key, required this.service, required this.productId});
final ProductService service;
final String productId;
Widget build(BuildContext context) {
final productState = useAutoComputedState(() => service.load(productId), keys: [productId]);
// Adds pull-to-refresh calling state.refresh(); keepInProgress keeps the last
// value on screen during the reload instead of flashing the loader.
return RefreshableComputedStateWrapper<Product>(
state: productState,
keepInProgress: true,
inProgressBuilder: (context) => const Center(child: CircularProgressIndicator()),
failedBuilder: (context) => const Center(child: Text('Something went wrong')),
builder: (context, product) => ListView(children: [Text(product.name)]),
);
}
}
Note keepInProgress: true above. A refresh puts the state back into inProgress, so valueOrNull returns null again and a naive View blinks to its loader even though it showed good data a frame ago. keepInProgress keeps the last ready value on screen during the reload. (The hook-level equivalent is wrapping the value in usePreviousIfNull.)
Search and gated reads
Two parameters carry most real-world reads. keys re-runs the compute when an input changes; debounceDuration delays that re-run so a search field doesn't fire on every keystroke; and shouldCompute gates the whole thing - while it is false, nothing runs:
class ProductSearch extends HookWidget {
const ProductSearch({super.key, required this.service});
final SearchService service;
Widget build(BuildContext context) {
final query = useFieldState();
final debounced = useDebounced(query.value, duration: const Duration(milliseconds: 300));
final resultsState = useAutoComputedState(
() => service.search(debounced),
keys: [debounced],
// Empty query: skip the fetch entirely instead of querying for "".
shouldCompute: debounced.isNotEmpty,
);
final results = resultsState.valueOrNull ?? const [];
return Column(
children: [
TextField(onChanged: (it) => query.value = it),
for (final result in results) Text(result.title),
],
);
}
}
shouldCompute is for logical prerequisites - "the user id is known", "auth is initialized" - not transient conditions. When it flips to false, the state is cleared back to notInitialized immediately and any in-flight load is cancelled. Gating on connectivity, for instance, would wipe the loaded data on every network blip.
// DO - gate on a real prerequisite
final dataState = useAutoComputedState(
() => repo.load(userId!),
keys: [userId],
shouldCompute: userId != null,
);
Driving it by hand
useAutoComputedState returns a MutableComputedState<T>, not a read-only snapshot. Three mutators sit beside the value:
refresh()- re-runscompute. If a load is already in flight it joins that one rather than stacking a second, so it is safe to call from several places.updateValue(T)- sets the value toready(value)without a round-trip. Use it after a mutation that already returns the updated entity.clear()- resets tonotInitializedand cancels any in-flight load.
class ProductEditor extends HookWidget {
const ProductEditor({super.key, required this.service, required this.productId});
final ProductService service;
final String productId;
Widget build(BuildContext context) {
final productState = useAutoComputedState(() => service.load(productId), keys: [productId]);
final saveState = useSubmitState();
void save(Product edited) => saveState.runSimple<Product, Never>(
submit: () => service.save(edited),
// The save returns the updated entity - reflect it without a round-trip.
afterSubmit: productState.updateValue,
);
final product = productState.valueOrNull;
return Column(
children: [
Text(product?.name ?? 'Loading...'),
if (product != null)
ElevatedButton(
onPressed: () => save(Product(product.id, '${product.name} (edited)')),
child: const Text('Save'),
),
],
);
}
}
Don't mirror a computed value in a parallel useState<T?> to "override" it after a local edit, and don't bump a useState<int> counter in keys to force a refresh. The first duplicates the source of truth and drifts from refresh(); the second hides a real dependency. Call updateValue for the override and refresh() for the imperative reload - reactive inputs belong in keys, imperative actions are method calls.
Errors and retry
A compute that throws lands its error in value as failed, which the wrappers render via failedBuilder - so a failed read is already visible, no try/catch needed. The auto-triggered load is fire-and-forget, so the failure also propagates to the app-level error pipeline.
Pass isRetryable: true (default false) to attach a Retryable handle to the thrown error, letting an app-level handler offer a "Try again" that re-runs compute. See Error handling for the full pipeline and the one caveat - a later retry is not re-gated through shouldCompute.
See also
- Async patterns - the read/write/stream model and the raw
useFutureprimitives this hook sits above - Pagination -
usePaginatedComputedState, the cursor-paginated cousin for lists that load in pages - Submit state - the write counterpart; pair a submit with a
refresh()to reload after a mutation - useAutoComputedState / useComputedState - signatures and full caveats
- ComputedStateWrapper - the View-side wrapper widgets