Skip to main content

useAutoComputedState

A useComputedState that refreshes itself: it runs compute on the first build and again whenever keys change. It returns the same MutableComputedState<T>, so the value, refresh(), updateValue, and clear() all behave identically - the difference is that the computation is triggered for you.

class UserScreen extends HookWidget {
final String userId;

const UserScreen({super.key, required this.userId, required this.service});

final UserService service;


Widget build(BuildContext context) {
// Runs on the first build, and again whenever userId changes.
final userState = useAutoComputedState(() => service.load(userId), keys: [userId]);

return userState.value.when(
notInitialized: () => const SizedBox.shrink(),
inProgress: (_) => const CircularProgressIndicator(),
ready: (user) => Text(user.name),
failed: (error) => Text('Failed: $error'),
);
}
}

Signature

MutableComputedState<T> useAutoComputedState<T>(
Future<T> Function() compute, {
bool shouldCompute = true,
HookKeys keys = hookKeysEmpty,
Duration debounceDuration = Duration.zero,
bool isRetryable = false,
});
  • compute - the async computation, run on first build and on every keys change (subject to shouldCompute).
  • shouldCompute (true by default) - gates the computation. When it is false, the state is cleared immediately (back to notInitialized) and any in-flight computation is cancelled. When it transitions back to true, compute runs again.
  • keys - a change to any key triggers a refresh. Pass the inputs compute depends on.
  • debounceDuration (Duration.zero by default) - delay that must pass after a keys change before compute fires. Useful for search-as-you-type.
  • isRetryable (false by default) - when true, errors are made Retryable so consumers can retry. Note that shouldCompute may have changed by the time a later Retryable.retry() runs, which can re-run compute under different conditions.

The return type is MutableComputedState<T> - see useComputedState for value, valueOrNull, refresh(), updateValue, and clear().

Use cases

  • Loading screen data when the screen opens, and reloading it when an input changes - the default tool for a one-shot read tied to the Screen / State / View pattern.

  • Search and filtering, where the query is a key and debounceDuration rate-limits the fetch:

    class UserSearch extends HookWidget {
    const UserSearch({super.key, required this.service});

    final UserService service;


    Widget build(BuildContext context) {
    final query = useFieldState();
    // 300ms must pass between keystrokes before the search fires.
    final resultsState = useAutoComputedState(
    () => service.search(query.value),
    keys: [query.value],
    debounceDuration: const Duration(milliseconds: 300),
    );

    return Column(
    children: [
    TextField(onChanged: (it) => query.value = it),
    for (final user in resultsState.valueOrNull ?? const <User>[]) Text(user.name),
    ],
    );
    }
    }
  • Reads that must wait for a prerequisite (an authenticated user, a non-null id). Gate them with shouldCompute so compute never runs with invalid inputs:

    class ProfileScreen extends HookWidget {
    final String? userId;

    const ProfileScreen({super.key, required this.userId, required this.service});

    final UserService service;


    Widget build(BuildContext context) {
    // The compute never runs with a null id - it waits until one is available.
    final userState = useAutoComputedState(
    () => service.load(userId!),
    shouldCompute: userId != null,
    keys: [userId],
    );

    return Text(userState.valueOrNull?.name ?? 'Waiting for id');
    }
    }

For cursor-paginated lists, use usePaginatedComputedState instead - a single Future<T> can't model loadMore. For an ongoing Stream<T>, use useMemoizedStream.

Caveats

  • shouldCompute: false clears the state. This is stricter than the paginated hook's gate - the moment it goes false, value returns to notInitialized and the in-flight computation is cancelled. Use it for logical prerequisites (userId != null), not transient conditions like connectivity, where you would lose the loaded data on every blip.

  • Use keys for reactive inputs, method calls for imperative actions. Don't bump a useState<int> counter just to force a refresh - the state already exposes refresh().

    // DON'T - a counter in keys carries no information, only "something happened"
    final triggerState = useState(0);
    final dataState = useAutoComputedState(() => repo.fetch(query), keys: [query, triggerState.value]);
    onRefresh: () => triggerState.value++;

    // DO - imperative action -> method call
    final dataState = useAutoComputedState(() => repo.fetch(query), keys: [query]);
    onRefresh: () => dataState.refresh();
  • A refresh puts the state back into inProgress, so valueOrNull becomes null and a naive View blinks to its loader. To keep the previous data on screen during a refresh, wrap the value in usePreviousIfNull, or use the useValueOrPrevious() extension on the state.

  • Errors from the auto-triggered first load aren't awaited anywhere, so they surface as unhandled zone errors (the let-it-crash default). The failure also lands in value as failed, so you can render it - but if you need to await the failure (e.g. in a test), gate with shouldCompute: false and call refresh() manually.

  • This hook pairs with the ComputedStateWrapper and RefreshableComputedStateWrapper widgets, which render the notInitialized / inProgress / ready / failed branches for you. Reach for them in the View instead of hand-writing a when(...).

See also