Skip to Content
State, identity, and ownership

State, identity, and ownership

A widget is immutable configuration. Noir throws it away and builds a new one whenever something changes. The values and resources that must survive that churn live somewhere else, and something has to release them.

This page answers two questions: what does Noir keep, and who releases it.

Configuration is replaceable; identity is not

Each widget in the tree has an Element. For a stateful widget, that element retains its State object. When a rebuild puts the same widget type and key at the same position, Noir updates the configuration and keeps the state. Keys also let compatible siblings keep their identity when they reorder; without keys, matching depends on position. Removing an element or replacing it with an incompatible type or key releases its state when the old element unmounts.

That is the whole reason the count in Your first app survives a label edit. The Text widget was replaced. _CounterAppState was not.

Inherited dependencies belong to the element tree as well. When an InheritedWidget reports a meaningful update, the elements that depend on it rebuild. Application code does not maintain a second subscription graph.

Let one State own the resource

Construct controllers and focus nodes as State fields when they do not depend on BuildContext. The same state reads the text, updates the applied filter, and releases both resources in dispose:

class FilterField extends StatefulWidget { const FilterField({super.key}); @override State<FilterField> createState() => _FilterFieldState(); } class _FilterFieldState extends State<FilterField> { final _controller = TextEditingController(); final _focusNode = FocusNode(); String _applied = 'No filter'; void _apply() { final value = _controller.text.trim(); setState(() { _applied = value.isEmpty ? 'No filter' : value; }); } @override void dispose() { _controller.dispose(); _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 1, children: [ TextInput( controller: _controller, focusNode: _focusNode, autofocus: true, placeholder: 'package name', onSubmit: _apply, ), Button(label: 'Apply filter', onPressed: _apply), Text('Filter: $_applied'), ], ); } }

The code that creates a FocusNode, TextEditingController, ScrollController, notifier, subscription, or AnimationController also disposes it. Finalizers are a safety net, not the normal lifecycle.

Rebuild after the value changes

Call setState after the underlying value changes. Keep its callback synchronous and limited to the state change; do network, file, or parsing work outside it.

setState schedules a rebuild for this state. Noir then rebuilds the dirty elements, and updates layout and records the frame from the render root. It does not replace _FilterFieldState, so the controller and the focus node keep their identity.

For delayed or asynchronous work, check that the state still exists before updating it:

final result = await catalog.search(query); if (!mounted) return; setState(() => _results = result);

When a newer request can supersede an older one, add a request generation as well as the mounted check. That stops a slow earlier response from replacing newer state.

Use the lifecycle method that matches the change

LifecycleUse it for
initStateOne-time construction and listener registration
didChangeDependenciesWork derived from inherited values such as Theme
didUpdateWidgetReconcile resources when a parent supplies new configuration
reassembleRe-derive cached values after hot reload, when initState will not rerun
deactivateTemporary removal from the active tree; do not dispose yet
disposeDeterministic listener removal and resource release

Hot reload calls reassemble on retained state, then rebuilds, lays out, and paints. Override it only when initState produced a cached value that must be recalculated, and call super.reassemble() first.

Retire a resource the old children still read

didUpdateWidget sometimes replaces a resource that descendants of the previous build still hold: a controller, a model, a subscription source. Disposing it there releases it while those children are still mounted. Register the release with deferDispose instead:

@override void didUpdateWidget(SearchPanel oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.query == widget.query) return; final retired = _model; _model = SearchModel(widget.query); deferDispose(retired.dispose); }

Noir runs the cleanup after this state updates its descendants and the removed descendants finish unmounting, or during unmount before dispose releases what the state currently owns. A failed initState, didUpdateWidget, or build keeps the resource alive until a later reconciliation succeeds. Release ordinary state-owned resources in dispose as before. deferDispose is only for a resource that outlives its replacement.

deferDispose is available in Noir 0.0.1. See Installation for package setup.

Choose the smallest clear owner

NeedDocumented choiceOwnership and observation
App-local state, core package onlyStatefulWidget and setStateThe State instance owns the fields and asks for the rebuild.
Reusable local lifecycle logicSignalWidget with useState or a controller hookThe host retains and releases the hook-owned resources.
Reactive values other values deriveuseSignal and useComputedThe hooks own the values and observe them.
State owned by a separate modeluseSignalValue or SignalValueBuilderThe widget observes; the model keeps the disposal responsibility.

The last three need the optional noir_signals package. Read Manage state and resources for hook order and cleanup, and Show shared state for the observation contract.

Two more choices stay useful in every application. Use ValueNotifier or ChangeNotifier when behavior should be testable independently of one widget. Use InheritedWidget to make stable data available to descendants without threading it through every constructor.

The repository’s Pulse animation  shows an AnimationController from initialization through tick handling and disposal.

Last updated on