Hooks internals
Hooks look like magic - useState returns a value that survives across rebuilds with no field to store it in - but the mechanism is small and worth understanding. Knowing how it works explains why the rules exist: why hook calls must keep a stable order, why keys compare element-wise, and how useKeyed legitimately runs hooks conditionally. None of this is API you call directly; it is the machinery underneath the catalog.
The hook context
Every hook runs inside a HookContext - the object that holds hook state between builds. A HookWidget is one such context; so is the HookProviderContainerWidget at the app root. During a build, the active context is set as HookContext.current, and the top-level use<T>(Hook<T>) function is just a shorthand for HookContext.current.use(hook):
T use<T>(Hook<T> hook) => HookContext.current!.use(hook);
Every hook in the catalog bottoms out in a use(...) call. useState, for instance, builds a StateHook and hands it to use. If there is no current context - you called a hook outside a build - use throws:
// DON'T - no HookContext is current here
void onPressed() {
final state = useState(0); // <- Throws: "Tried to use() a hook without an available HookContext"
}
The context distinguishes two things: the Hook - a cheap, const-constructible description rebuilt every time (StateHook(0)) - and the HookState - the long-lived object that actually holds the value across rebuilds. You write and pass Hooks; the context manages the HookStates.
The call-order rule
A context stores its hook states in a plain list and walks it with an index that resets to zero at the start of every build:
- First build: each
use(hook)creates a freshHookState, appends it to the list, and advances the index. - Every later build: each
use(hook)looks up the state already at the current index, updates it with the newHook, advances the index.
Hook state is therefore matched between builds purely by position. The first useState in your function is always slot 0, the second is always slot 1. Nothing about the call is recorded except the order it happened in - which is exactly why the order must not change.
// DON'T - the hook count changes between builds
if (isLoggedIn) {
final profile = useState<Profile?>(null); // <- Slot 0 only on some builds
}
final counter = useState(0); // <- Slot 0 or slot 1, depending on isLoggedIn
// DON'T - the loop length drives the hook count
for (final id in ids) {
useState(id); // <- Slots shift whenever ids changes length
}
If isLoggedIn flips, counter lands in a different slot and inherits the wrong HookState. In debug mode the context catches this and throws rather than letting state leak silently - it checks the running hook's type against the type stored at that index:
Trying to change hook type after the first build
Hook types cannot be changed after the first build
It also throws Trying to add a hook after the first build if a build runs more hooks than the first one did, and Hooks have been removed during build if it runs fewer. These assertions only run in debug mode; release builds trust the order and silently misattribute state, which is why the rule matters even though violating it sometimes appears to work.
The fix is never to branch around a hook call. Always call the hook, and push the condition inside it (useState(isLoggedIn ? a : b)), or use the control-flow hooks below.
One more guard: no hooks inside hooks
A HookState's build() must not itself call use. The context tracks the hook currently building and throws if another use starts while one is in flight:
Trying to use a hook from inside a hook
Split your hooks into atomic parts and use composition
This is why a custom hook composes by calling other hooks in its own body, never from inside a useMemoized callback or a HookState. A compute callback passed to useMemoized runs as part of another hook's build, so it is off-limits for hook calls - the same reason named local functions in hooks must stay pure.
The HookState lifecycle
Each HookState goes through a fixed lifecycle, and the catalog hooks are built by overriding these methods:
| Method | When it runs |
|---|---|
createState() | Once, on the first build, to produce the HookState |
init() | Once, immediately after the state is created and mounted |
build() | Every build - returns the hook's current value |
didUpdate(oldHook) | Every build except the first, but only when the new Hook differs from the previous one (hook != oldHook) |
dispose() | Once, when the context is torn down (widget removed) or the hook is disposed by a nested context |
mounted is true only between init and dispose. This is the flag behind useIsMounted: an async callback that resolves after the widget is gone can check mounted before touching state, instead of throwing.
This lifecycle is also why a value set on useState during a build throws while the same write from a callback is fine - the build is reading slot by slot, and mutating a state mid-build would desync the index walk from the values already returned.
Keys and list equality
Many hooks take keys - a HookKeys, which is just List<Object?>:
typedef HookKeys = List<Object?>;
Keys are compared with ListEquality from package:collection - element-wise, using each element's ==. The constant hookKeysEmpty (const []) is the default "no keys" value, and a small HookKeysEquatable wrapper gives a key list value-equality so it can be used as a map key (the nested-hook machinery below relies on that).
The consequence is the one stated on every hook that takes keys: keys must have meaningful value equality.
// DON'T - a fresh list instance every build is still element-wise equal,
// but a fresh *object* with no == override is not
useMemoized(() => parse(config), [Config()]); // <- New Config() each build re-runs every time
// DO - key on value-equal data (ids, primitives, value types)
useMemoized(() => parse(config), [config.id]);
A KeyedHook compares its new keys against the previous build's; when they differ element-wise, it fires didUpdateKeys(). That single hook is the basis of keys: on useState (reset the value), useMemoized (recompute), useEffect (re-run), and the nested hooks below (rebuild the nested context).
Nested hooks: running hooks conditionally
The call-order rule forbids hooks inside an if or a loop. useKeyed, useIf, and useMap lift that restriction - and the mechanism is the missing piece that makes them not a violation.
A nested hook owns a child hook context of its own. The hooks you run inside the block run against that child context, with their own independent slot list. So the parent context still sees a single, stable hook (useKeyed itself, always in the same slot); the variability is confined to the child, which is allowed to be rebuilt wholesale.
useKeyedholds one child context keyed by the values you pass. When the keys change, it disposes that child context entirely - every hook inside is unmounted - and builds a fresh one. That is why auseStateinsideuseKeyedresets on a key change, and why in-flight async work is dropped: the whole child context was torn down.useMapholds a map of child contexts, one per key in the set. Each build, it runs the block for every current key against that key's own context; after the build, contexts whose keys are no longer present are disposed. Existing keys keep their state, new keys start fresh, removed keys are cleaned up.
useIf and useIfNotNull are thin wrappers over useKeyed - useIf(condition, block) is useKeyed([condition], () => condition ? block() : null), so the child context is keyed on the boolean and torn down when the condition flips.
The trade-off is exactly the disposal behavior: these hooks reset their inner state whenever the key changes. That is the point - it is how you reset a group of hooks together - but it means keying on a value that changes often rebuilds the nested context constantly. Key on the coarsest signal that still gives you the reset you want.
The debug tree and useDebugGroup
If you inspect a HookWidget in the Flutter DevTools diagnostics tree, the hooks appear as a labeled, nested tree rather than a flat list - useFieldState shows up as one node grouping its inner useStates, useSubmitState as another. That grouping is produced by an internal mechanism, useDebugGroup.
useDebugGroup is not part of the public API - it is not exported from package:utopia_hooks, and you do not call it in app code. It exists so the library's own composite hooks can label their internal structure. In debug mode it wraps a block in a tiny hook that carries its own child context (so the inner hooks become a subtree) plus a debug label; in release mode it compiles down to calling the block directly, with zero overhead:
// Internal mechanics - not callable from app code
T useDebugGroup<T>(T Function() block, {String? debugLabel, ...}) {
if (kDebugMode) {
return use(_DebugGroupHook(block, ...)); // <- Inner hooks form a labeled subtree
}
return block(); // <- Release: no wrapper, no cost
}
This is why composite hooks like useFieldState, useSubmitState, and useCombinedInitializationState read as a single named entry in the debug tree even though each runs several hooks inside. When you build a custom hook, you get the grouping for free if it is built from hooks that already use it - there is nothing to call and nothing to maintain.
See also
- Custom hooks - composing your own hooks under these rules
- useKeyed - the nested-context primitive behind conditional hooks
- useMap - the per-key nested-context generalization
- useState - the canonical hook, and
keys/listenin practice - Basics - the rules of hooks stated without the internals