Skip to main content

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]));

See useIf and useMap.

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,
}) {
// ...
}

See Navigation is a callback.

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