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 thebuild
method of aWidget
.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 rebuildMaking 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 touseState
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 touseEffect
, 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 viauseIsMounted
hook:final state = useState(0);
final isMounted = useIsMounted();
useEffect(() async {
await Future.delayed(Duration(seconds: 1));
if(isMounted()) state.value++;
});
- If the keys change during the execution of the
-
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 valuepreserveState
(true
by default) - If set to true, the last value will be preserved when the instance ofFuture
/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 whyuseFuture
/useStream
is usually paired withuseMemoized
which creates a newFuture
/Stream
only when desired:final future = useMemoized(() async => getData(state.value), [state.value]);
final snapshot = useFuture(future);The
useMemoizedFuture
,useMemoizedFutureData
,useMemoizedStream
anduseMemoizedStreamData
hooks are provided for convenience and are equivalent to a given hook paired withuseMemoized
:final snapshot = useMemoizedFuture(() async => getData(state.value), keys: [state.value]);
-
If the
Future
/Stream
emits an error, it will be available in theerror
property ofAsyncSnapshot
. To avoid silently ignoring the error it should be handled explicitly. One way to do this is viauseAsyncSnapshotErrorHandler
:final snapshot = useFuture(someFuture);
useAsyncSnapshotErrorHandler(
snapshot,
onError: (error, stackTrace) => showErrorSnackBar(...),
);If no
onError
parameter is provided, the error will be reported toZone.handleUncaughtError
, which usually triggers an application-wide error reporting mechanism (e.g. Crashlytics). Alternatively,useFutureData
anduseStreamData
already useuseAsyncSnapshotErrorHandler
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 makesuseStream
for handling "event streams", where every value should be handled. Consider usinguseStreamSubscription
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
orRawProvider
(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 InheritedWidget
s 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
InheritedWidget
s 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 usinguseBuildContext
directly:SomeState useSomeState({required Locale locale}) {
// ...
}
// ... e.g. in a HookWidget
Widget build(BuildContext context) {
final state = useSomeState(locale: Localizations.localeOf(context));
// ...
}
See alsoβ
- Hooks library - A complete list of hooks provided
by
utopia_hooks
- Custom hooks - A guide on creating custom hooks