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
| Lifecycle | Use it for |
|---|---|
initState | One-time construction and listener registration |
didChangeDependencies | Work derived from inherited values such as Theme |
didUpdateWidget | Reconcile resources when a parent supplies new configuration |
reassemble | Re-derive cached values after hot reload, when initState will not rerun |
deactivate | Temporary removal from the active tree; do not dispose yet |
dispose | Deterministic 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
| Need | Documented choice | Ownership and observation |
|---|---|---|
| App-local state, core package only | StatefulWidget and setState | The State instance owns the fields and asks for the rebuild. |
| Reusable local lifecycle logic | SignalWidget with useState or a controller hook | The host retains and releases the hook-owned resources. |
| Reactive values other values derive | useSignal and useComputed | The hooks own the values and observe them. |
| State owned by a separate model | useSignalValue or SignalValueBuilder | The 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.