Skip to main content

Common hooks

utopia_hooks contains a variety of hooks covering basic and complex use cases. While the Basics guide introduces useState and useEffect hooks, this guide covers them in more detail and introduces other most common hooks.

useState​

Represents a single, mutable value of any type. At the beginning it's set to the provided initial value, but it can be changed, triggering a rebuild in the process.

final state = useState<String?>(null);

// ... get the current value
Text(state.value ?? 'No value');

// ... update the value
onPressed: () => state.value = 'Hello';

Use cases​

Any value that can change over time and needs to be reflected in UI or other hooks, e.g.:

  • Value of a form field
  • Current page of a PageView
  • An instance of a complex object that initializes asynchronously (e.g. a database connection)

Caveats​

  • The initial value is only used on the first build. After that it will be ignored, even if it changes.

    final stateA = useState(0);

    // DON'T
    final stateB = useState(stateA.value);

    // ...
    onPressed: () => stateA.value = 1; // <- This won't affect the value of stateB
  • The value can't be changed during the build. This is similar to how setState can't be called in the build method of a Widget.

    final stateA = useState(0);

    // DON'T
    state.value++; // <- Will throw

    // DO
    useEffect(() {
    state.value++; // <- Won't throw since effects are called after the build
    });

    // DO
    onPressed: () => state.value++; // <- Won't throw since this is called as a result of user interaction
  • If the stored object is mutated directly, the hook won't be rebuilt. This is why it's recommended to use immutable objects with useState. In case of standard Dart mutable collections (List, Map, etc.) it's required to make a copy of the collection before mutating it.

    final state = useState([0]);

    // DON'T
    onPressed: () => state.value.add(1); // <- This won't trigger a rebuild

    // DO
    onPressed: () => state.value = [...state.value, 1]; // <- This will trigger a rebuild

    Making a copy of the collection every time it's mutated is suboptimal and error-prone, so it's generally recommended to use immutable collections, like those provided by package:fast_immutable_collections.

    // DO
    final state = useState(IList([0]));

    onPressed: () => state.value = state.value.add(1); // <- This will also trigger a rebuild
  • If a new value is equal (via ==) to the previous value, widget won't be rebuilt. This helps to avoid unnecessary rebuilds, but it can cause problems when using objects with incorrectly implemented == operator.

  • In some cases it may not be necessary to rebuild the hook when the state changes, e.g. when its value is used only in callback. In such cases listen: false can be passed to useState to disable the rebuild:

    // CAREFUL
    final state = useState(0, listen: false);

    onPressed: () => print("The button has been pressed ${state.value++} times");

    However, this optimization should be used carefully since it can lead to unexpected behavior if the developer expects the hook to rebuild after the value changes.

useEffect​

Registers a function to be called when any of the keys change. The function can optionally return a "dispose" function which will be called:

  • When the Hook's context is destroyed (e.g. when the widget is removed from the tree)
  • When the keys change, before the next side effect is called
useEffect(() {
print("Initialized");
return () => print("Disposed");
});

useEffect(() {
final currentValue = state.value; // <- Capture the current value so it's remembered by the dispose function
print("The value is now $currentValue");
return () => print("The value is no longer $currentValue");
}, [state.value]);

useEffect(() {
// Dispose function doesn't have to be returned if it's not needed
if(isEnabled) {
print("Creating a resource");
return () => print("Disposing the resource");
}
}, [isEnabled]);

useEffect(() async {
// Async functions can be used, but they won't be "waited for"
await Future.delayed(Duration(seconds: 1));
print("This will be called after 1 second");
});

Use cases​

  • Initialization & cleanup of resources (e.g. opening & closing a database connection)
  • Reacting to changes in values from other hooks
  • Asynchronous operations (e.g. showing a dialog after fixed duration)

Caveats​

  • Effect is triggered only after any of the keys change, so it's usually desired to include all the values used by the effect in the keys; however, sometimes it's useful to omit some of the values to have a more fine-grained control on when the effect is triggered.

  • Effects are called after the build. This means that the effect won't block the build and allows the effect to trigger rebuilds (e.g. by changing a value of setState). In rare circumstances when this is not desired (e.g. initializing an object that's needed during the build), useImmediateEffect can be used instead.

  • async functions can be passed to useEffect, but they won't be "waited for". This has the following consequences:

    • If the keys change during the execution of the async function, a second effect will be called in parallel.
    • A dispose function returned from an async effect will have no effect.
    • async function can "live" longer than the context in which it was started, so it's generally recommended to ensure that the context is still valid after an asynchronous operation via useIsMounted hook:
      final state = useState(0);
      final isMounted = useIsMounted();

      useEffect(() async {
      await Future.delayed(Duration(seconds: 1));
      if(isMounted()) state.value++;
      });
  • Since effect is a regular function, it captures the values of variables at the moment of its creation. This sometimes can lead to unexpected behavior:

    final state = useState(0);
    final computed = state.value + 1;

    useEffect(() {
    // This will always print "1", even if the value of "state" changes since it captures the value of "computed" at the
    // moment of execution of the effect.
    final timer = Timer.periodic(Duration(seconds: 1), (_) => print(computed));
    return timer.cancel;
    }); // <- No keys, so the effect won't be called again when the value of "state" changes.

    If adding the captured value to the keys is not desired, this problem can also be avoided using the useValueWrapper hook.

useMemoized​

Caches the result of a synchronous function and returns it on subsequent calls, optionally re-computing it when any of the keys change. An optional dispose function can be provided to clean up any resources created by the function.

final value = useMemoized(() => expensiveComputation(a, b), [a, b]);

final controller = useMemoized(TextEditingController.new, [], (it) => it.dispose());

Use cases​

  • Caching the result of a synchronous computation that's expensive to compute
  • Creating long-lived objects (like Flutter controllers)

Caveats​

  • The computation is called during build, so if it's really expensive it can still cause performance issues. Consider moving it to an effect to perform it after the build or to a separate isolate altogether.

useFuture / useStream​

Listens to Future/Stream and exposes its current value. Similar to FutureBuilder/StreamBuilder widgets.

Both return AsyncSnapshot and take two optional parameters:

  • initialData (null by default) - Value to use before the Future/Stream emits a value
  • preserveState (true by default) - If set to true, the last value will be preserved when the instance of Future/Stream changes, before a new value is emitted.
final futureSnapshot = useFuture(someFuture);
final streamSnapshot = useStream(someStream);

// ...
Text(futureSnapshot.data ?? 'Loading...');
Text(streamSnapshot.data ?? 'Loading...');

Since usually only the data property of AsyncSnapshot is needed, useFutureData and useStreamData hooks are provided for convenience. They take the same parameters as useFuture/useStream and return the value directly:

final futureData = useFutureData(someFuture);

// ...
Text(futureData ?? 'Loading...');

Use cases​

  • Querying data from API / database (however, take a look at useAutoComputedState which may be better suited for this case)
  • Displaying values from an updating stream (e.g. GPS location)

Caveats​

  • If the instance of Future/Stream changes, the old subscription will be cancelled and a new one will be created. This is why useFuture/useStream is usually paired with useMemoized which creates a new Future/Stream only when desired:

    final future = useMemoized(() async => getData(state.value), [state.value]);
    final snapshot = useFuture(future);

    The useMemoizedFuture, useMemoizedFutureData, useMemoizedStream and useMemoizedStreamData hooks are provided for convenience and are equivalent to a given hook paired with useMemoized:

    final snapshot = useMemoizedFuture(() async => getData(state.value), keys: [state.value]);
  • If the Future/Stream emits an error, it will be available in the error property of AsyncSnapshot. To avoid silently ignoring the error it should be handled explicitly. One way to do this is via useAsyncSnapshotErrorHandler:

    final snapshot = useFuture(someFuture);
    useAsyncSnapshotErrorHandler(
    snapshot,
    onError: (error, stackTrace) => showErrorSnackBar(...),
    );

    If no onError parameter is provided, the error will be reported to Zone.handleUncaughtError, which usually triggers an application-wide error reporting mechanism (e.g. Crashlytics). Alternatively, useFutureData and useStreamData already use useAsyncSnapshotErrorHandler under the hood.

  • useStream always exposes only the latest value of the stream, so if it emits multiple values before the builds, intermediate values will be "lost". This makes useStream for handling "event streams", where every value should be handled. Consider using useStreamSubscription for this case instead.

useProvided​

Retrieves a value from the context and rebuilds the hook when it changes.

The available values are dependent on the context and include:

  • Global states (see Hook-based architecture guide)
  • Anything provided via ValueProvider, HookProvider or RawProvider (see Provider guide)
  • BuildContext (see below)
final globalState = useProvided<GlobalState>(); // <- A global state provided by HookProviderContainerWidget
final parentConfiguration = useProvided<ParentConfiguration>(); // <- Value provided by a parent widget via ValueProvider

useEffect(() {
print("Global state changed to ${globalState.value}");
}, [globalState.value]);

In general, it's recommended to place useProvided calls together at the top of the hook, making it easier to reason about the structure of the dependencies.

useBuildContext​

A useBuildContext hook is equivalent to useProvided<BuildContext>() and allows hooks to interact with Flutter. Hooks can also create dependencies on InheritedWidgets like MediaQuery, causing them to rebuild when the widget changes:

final context = useBuildContext();
final size = MediaQuery.sizeOf(context); // <- Will rebuild when the size changes

Caveats​

  • While it's possible to retrieve InheritedWidgets in effects, it's not recommended since they won't be automatically called again when the value changes. The value can be retrieved directly in the build, and then added to the effect's keys instead:

    final context = useBuildContext();

    // CAREFUL
    useEffect(() => print("The current locale is ${Localizations.localeOf(context)}"));

    // DO
    final locale = Localizations.localeOf(context);
    useEffect(() => print("The current locale is $locale"), [locale]);
  • useBuildContext can make hooks difficult to unit-test. In cases when this is a concern, it can be avoided by passing the BuildContext-dependent data to the hook via its arguments instead of using useBuildContext directly:

    SomeState useSomeState({required Locale locale}) {
    // ...
    }

    // ... e.g. in a HookWidget
    Widget build(BuildContext context) {
    final state = useSomeState(locale: Localizations.localeOf(context));
    // ...
    }

See also​