Do's and don'ts
The mistakes that come up most often when writing utopia_hooks code, each as a short pair: the tempting wrong way, then the right way. Every snippet here is illustrative - the // DON'T halves intentionally do not compile or do not behave as written.
For the reasoning behind each rule, follow the link to the hook's own page.
Don't mutate a useState collection in place
A useState only rebuilds when its value is replaced. Mutating the stored object leaves the hook unaware that anything changed.
final itemsState = useState<List<Task>>([]);
// DON'T
onPressed: () => itemsState.value.add(task); // <- No rebuild; the reference is unchanged
// DO
onPressed: () => itemsState.value = [...itemsState.value, task]; // <- New reference, rebuild fires
Copying on every write is error-prone, so prefer an immutable collection like IList and assign its result back:
final itemsState = useState<IList<Task>>(const IList.empty());
// DO
onPressed: () => itemsState.modify((it) => it.add(task)); // <- modify writes the returned value back
See useState caveats.
Don't call hooks conditionally or in loops
Hooks are matched across rebuilds by call order. An if or a loop changes how many hooks run, which desynchronizes every hook after it.
// DON'T - hook order changes when isExpanded flips
if (isExpanded) {
final details = useAutoComputedState(() => service.loadDetails(id), keys: [id]);
}
// DON'T - hook count tracks the list length
for (final id in courseIds) {
final state = useAutoComputedState(() => service.load(id), keys: [id]);
}
Reach for the nested hooks instead - they keep a stable outer slot and run the inner hooks in a managed child context:
// DO - one conditional hook
final details = useIf(isExpanded, () => useAutoComputedState(() => service.loadDetails(id), keys: [id]));
// DO - one hook instance per key, stable as the set changes
final states = useMap(courseIds.toSet(), (id) => useAutoComputedState(() => service.load(id), keys: [id]));
Don't set state during the build
Writing to a useState while the hook body is running throws, exactly like calling setState inside build. Move the write into an effect or a callback.
final countState = useState(0);
// DON'T
countState.value++; // <- Throws; this runs during the build
// DO - effects run after the build
useEffect(() {
countState.value++;
return null;
}, const []);
// DO - callbacks run in response to interaction, not during build
onPressed: () => countState.value++;
See useState caveats.
Don't derive state with useEffect
A value computed from other state is not state - it is a derivation. Storing it in a useState and syncing it from an effect adds a redundant variable and an extra rebuild, and the two can drift.
// DON'T - mirror state into another state via an effect
final sortedState = useState<IList<Task>?>(null);
useEffect(() {
sortedState.value = tasks?.sortedBy((it) => it.dueDate).toIList();
return null;
}, [tasks]);
// DO - compute it directly, recomputed only when inputs change
final sorted = useMemoized(() => tasks?.sortedBy((it) => it.dueDate).toIList(), [tasks]);
This is also how you avoid cascading effects (effect A writes state B, which triggers effect B, ...). See useMemoized.
Don't forget to guard async writes after dispose
A Future or Stream callback can resolve after the widget is gone. Writing .value then throws in debug mode. Guard with setIfMounted (or a useIsMounted check).
final progressState = useState(0.0);
// DON'T
Future<void> track(Stream<double> events) async {
await for (final p in events) {
progressState.value = p; // <- Throws once the hook is unmounted
}
}
// DO
Future<void> track(Stream<double> events) async {
await for (final p in events) {
if (!progressState.setIfMounted(p)) return; // <- Stops once unmounted
}
}
See useState caveats and useIsMounted.
Don't read an InheritedWidget inside an effect without a key
An effect captures its closure once and re-runs only when its keys change. Reading Theme.of(context) or Localizations.of(context) inside the effect means it never sees later changes. Read it in the build body and pass it as a key.
// DON'T - the effect never re-runs when the locale changes
useEffect(() {
analytics.setLocale(Localizations.localeOf(context));
return null;
}, const []);
// DO - read during build, drive the effect with it
final locale = Localizations.localeOf(context);
useEffect(() {
analytics.setLocale(locale);
return null;
}, [locale]);
See useEffect.
Don't assume useSubmitState blocks duplicate runs
useSubmitState counts in-flight runs to drive inProgress - it does not stop a second tap from starting a second run. A double-tap fires the action twice unless you guard it.
final saveState = useSubmitState();
// DON'T - a fast double-tap runs save() twice
onPressed: () => saveState.runSimple<void, Never>(submit: () async => service.save(data));
// DO - skip while a run is already in flight
onPressed: () => saveState.runSimple<void, Never>(
skipIfInProgress: true,
submit: () async => service.save(data),
);
// DO - useSubmitButtonState returns a ButtonState whose onTap guards re-entry itself
final saveButton = useSubmitButtonState(() async => service.save(data));
// In the View: ElevatedButton(onPressed: saveButton.onTapIfEnabled, child: const Text("Save"))
useSubmitButtonState wires the guard for you; a bare toButtonState() only drives loading, so guard those taps with skipIfInProgress. See useSubmitState.
Don't call useProvided or useInjected in the View
The View is a StatelessWidget, not a hook context - those calls have no hook to attach to. Everything the View needs is already on state; global state and services are read in the state hook.
// DON'T - the View reaches for global dependencies
class TasksScreenView extends StatelessWidget {
Widget build(BuildContext context) {
final auth = useProvided<AuthState>(); // <- No hook context here
final service = useInjected<TaskService>();
// ...
}
}
// DO - the state hook reads them; the View only reads `state`
TasksScreenState useTasksScreenState() {
final auth = useProvided<AuthState>();
final service = useInjected<TaskService>();
// ...
}
See useProvided and Screen / State / View.
Don't pass BuildContext into a state hook
It looks cheaper than three callbacks, but it lets the hook call Navigator.of(context) or XDialog.show(context) internally - the navigation wiring the Screen owns - and makes the hook untestable without a widget tree. Navigation is a callback.
// DON'T - the hook does dialog/navigation wiring
HabitScreenState useHabitScreenState({
required Habit habit,
required BuildContext context, // <- never
}) {
void onEditPressed() => EditHabitDialog.show(context);
// ...
}
// DO - the Screen builds callbacks from its context; the hook receives them
HabitScreenState useHabitScreenState({
required Habit habit,
required Future<void> Function() navigateToEdit,
}) {
// ...
}
Don't manage a TextEditingController by hand
A controller has its own lifecycle (focus, selection, composing region) that does not compose with hook rebuilds. A useEffect that writes controller.text stomps on what the user is typing and loses the cursor. Use useFieldState as the source of truth.
// DON'T - the effect overwrites in-progress input
final controller = useMemoized(TextEditingController.new);
useEffect(() {
controller.text = externalValue; // <- Fights the user's edits
return null;
}, [externalValue]);
// DO - field state owns the value; TextEditingControllerWrapper owns the controller
final nameField = useFieldState(initialValue: externalValue);
// In the View:
TextEditingControllerWrapper(
text: state.nameField,
builder: (controller) => TextField(controller: controller),
);
See useFieldState.
See also
- Modeling state - shaping the State class these rules act on
- Naming conventions - the names that make the three parts findable
- Hook catalog - per-hook caveats in full
- Screen / State / View - where each hook is allowed to live