Flutter conventions
These are the Dart and Flutter conventions that keep hook-based code consistent and bug-resistant. A few of them are not stylistic - they close off entire classes of bug that hooks are especially prone to (missed rebuilds from mutated collections, controllers that fall out of sync, broken hook ordering). They apply to every .dart file, not just state hooks.
Immutability and collections
State flows through hooks by value. useState and useMemoized decide whether to rebuild by comparing the new value to the old with ==, so mutating a value in place produces no rebuild - the reference is unchanged.
This bites hardest with the standard mutable collections. Mutating a List held in useState does nothing:
final state = useState<List<Task>>([]);
// DON'T
onPressed: () => state.value.add(task); // <- Same List instance; no rebuild
// DO - assign a new instance
onPressed: () => state.value = [...state.value, task]; // <- New instance; rebuilds
Copying on every change is error-prone, so projects standardize on the immutable collections from package:fast_immutable_collections - IList, IMap, ISet - in state fields, hook return types, and function signatures. Every mutating operation returns a new instance, which is exactly what useState needs:
// DO - IList in state; every operation returns a new value
final state = useState<IList<Task>>(const IList.empty());
onPressed: () => state.value = state.value.add(task); // <- Returns a new IList; rebuilds
Prefer immutable model classes for the same reason - a useState<User> only rebuilds when assigned a new User, never when an existing one is mutated.
Hook ordering
Hooks are matched between builds by call order (the mechanics are in Hooks internals), which dictates two ordering conventions.
First, never call a hook conditionally or in a loop. Push the condition inside the hook, or use a control-flow hook:
// DON'T - changes the hook order between builds
if (isEditing) {
final draft = useState(initial); // <- Only some builds reach this line
}
// DO - the call is unconditional; the condition is data
final draftState = useIf(isEditing, () => useState(initial));
Second, place all useProvided and useInjected calls together at the top of the hook. Dependencies are then visible at a glance, and the rest of the hook reads as logic over already-resolved inputs:
ProfileScreenState useProfileScreenState() {
// Dependencies first
final auth = useProvided<AuthState>();
final profileService = useInjected<ProfileService>();
// ...then the logic
}
Named local functions in hooks
When a hook needs a non-trivial compute or callback, give it a named local function and pass it to useMemoized (or use it as an action) - do not extract a private top-level helper.
int useCustomState(int a) {
// Local function: captures `a` by closure, names the computation, and stays
// next to the hook that owns it. It must stay pure - no hook calls inside.
int computeNext() => a + 1;
return useMemoized(computeNext, [a]);
}
A local function captures the hook's parameters and state by closure, so there are no arguments to re-thread; it names the computation, which reads better than an inline () => …; and it lives next to the hook that owns it instead of polluting the file's top level.
// DON'T - private top-level helper re-threads params the closure could capture
int _computeNext(int a) => a + 1;
int useCustomState(int a) => useMemoized(() => _computeNext(a), [a]);
A local function used this way must stay a pure compute or callback - it must not call hooks (useState, useMemoized, useInjected, …). Hooks run only in the hook's own build body, in a stable order; a hook call nested inside a function breaks that order. For conditional or looped hook logic, reach for useIf / useKeyed / useMap instead.
Still extract a top-level function when the logic is genuinely pure, reused across files, and captures no hook state - then it is a shared utility, not hook-local glue.
Text fields: always useFieldState + TextEditingControllerWrapper
This is the strictest convention, because the obvious approaches all break. Never manage a TextEditingController directly from a hook. A TextEditingController carries its own lifecycle - focus, selection, composing region - that does not compose with hook rebuilds:
// DON'T - useMemoized won't rebuild the controller when externalValue changes,
// and writing to .text from an effect stomps on the user's cursor
final controller = useMemoized(TextEditingController.new);
useEffect(() {
controller.text = externalValue; // <- Loses cursor position; fights user input
return null;
}, [externalValue]);
// DON'T - manual dispose leaks on hot reload and double-disposes on key change
final controller = useMemoized(() => TextEditingController(text: initial));
useEffect(() => controller.dispose, const []);
The correct pattern keeps useFieldState as the single source of truth in the state hook:
class EditProductScreenState {
final MutableFieldState nameField;
final bool isSaving;
final void Function() onSavePressed;
const EditProductScreenState({
required this.nameField,
required this.isSaving,
required this.onSavePressed,
});
}
EditProductScreenState useEditProductScreenState({
required String initialName,
required void Function() navigateBack,
}) {
final nameState = useFieldState(initialValue: initialName); // the source of truth
final saveState = useSubmitState();
return EditProductScreenState(
nameField: nameState,
isSaving: saveState.inProgress,
onSavePressed: () => saveState.runSimple<void, Never>(
submit: () async => _update(name: nameState.value),
afterSubmit: (_) => navigateBack(),
),
);
}
The View renders the field through TextEditingControllerWrapper, which owns the controller lifecycle and keeps it bi-directionally in sync with the field state - including the case the manual approaches get wrong, an external value changing while the user is typing:
class EditProductScreenView extends StatelessWidget {
final EditProductScreenState state;
const EditProductScreenView({super.key, required this.state});
Widget build(BuildContext context) {
return Column(
spacing: 8,
children: [
// TextEditingControllerWrapper owns the controller and stays in sync
// with the field state - no manual controller, no useEffect on .text.
TextEditingControllerWrapper(
text: state.nameField,
builder: (controller) => TextField(
controller: controller,
decoration: const InputDecoration(labelText: 'Name'),
),
),
ElevatedButton(
onPressed: state.isSaving ? null : state.onSavePressed,
child: const Text('Save'),
),
],
);
}
}
Projects usually wrap TextEditingControllerWrapper in their own field widget (AppTextField(state: nameField)) for visual consistency - that is fine. The contract stays the same: the field state is the source of truth, and the wrapper owns the controller. A hook-owned FocusNode (useFocusNode()) follows the same rule - never drive focus from a useEffect watching an external flag.
Function style
If a function body formats to more than two lines, use the curly-brace form rather than an arrow - it keeps diffs readable and formatting consistent. Arrows are fine for genuine one-liners (bool get isValid => name.isNotEmpty). Prefer it as the parameter name for single-argument closures (items.where((it) => it.isActive)), and let Dart infer types (final count = items.length, not final int count = …) except where inference fails or a complex generic reads better explicit.
See also
- Custom hooks - where the local-function convention is applied
- Hooks internals - why hook ordering is load-bearing
- useFieldState - the form-field source of truth
- TextEditingControllerWrapper - the controller lifecycle owner
- Forms & fields - field state and validation in context