Skip to Content
Manage state and resources

Manage state and resources

Use this guide when a widget needs its own value, controller, or external resource, and you want that concern named and reusable instead of spread over several State fields and lifecycle methods.

The optional noir_signals package adds SignalWidget, a host that retains hooks. Each hook owns what it creates and releases it when the widget unmounts.

import 'package:noir/noir.dart'; import 'package:noir_signals/noir_signals.dart';

Own a resource and its cleanup together

This widget owns an observed count and a periodic timer. The effect starts the timer and returns the callback that cancels it. Noir runs that callback when the interval changes or the widget leaves the tree:

import 'dart:async'; import 'package:noir/noir.dart'; import 'package:noir_signals/noir_signals.dart'; class PollingBadge extends SignalWidget { const PollingBadge({required this.interval, super.key}); final Duration interval; @override Widget build(BuildContext context) { final ticks = useState<int>(0); useEffect(() { final timer = Timer.periodic(interval, (_) { ticks.value++; }); return timer.cancel; }, <Object?>[interval]); return Row( spacing: 1, children: [ const Badge(label: 'POLLING', variant: BadgeVariant.info), Text('${ticks.value} checks'), ], ); } }

useState owns and observes a ValueNotifier. Changing ticks.value requests a rebuild. The timer callback runs later, after the effect has returned, so it can update that value safely.

Three rules keep this example correct:

  • Call hooks unconditionally, at the top of build, in the same order on every build. Hooks use call order as identity.
  • The key list decides when a hook replaces its resource. [interval] installs a new timer and cancels the old one when the interval changes. An empty list keeps one resource for the life of the mounted slot. No key list at all runs the cleanup and the effect on every build.
  • An effect runs synchronously during build. It must not change an observed hook value before it returns. Start external work, return its cleanup, and handle input in onPressed.

The hooks contract  is the canonical reference for replacement order, cleanup failures, and the runtime guard behind that third rule.

Match the hook to the job

NeedStart with
Observed local valueuseState
Retained object, Future, or Stream identityuseMemoized
Acquire and release an external resourceuseEffect
Read a Listenable and rebuilduseListenable or useValueListenable
Editable text, focus, or scrollinguseTextEditingController, useFocusNode, or useScrollController
Time-based valuesuseAnimationController and useAnimation
Mutable bookkeeping that must not rebuilduseRef

Create a Future or a Stream outside build, or retain it with useMemoized. Creating one on every build restarts the asynchronous work.

Reuse one concern as a custom hook

A custom hook is a top-level function whose name starts with use. Keep the owned notifier private when callers need only a value and a callback:

typedef CounterState = ({int value, VoidCallback increment}); CounterState useCounter([int initialValue = 0]) { final counter = useState(initialValue); return (value: counter.value, increment: () => counter.value++); }

Use SignalBuilder when a small, stable subtree inside an ordinary StatefulWidget needs hooks.

When a State class is clearer

Use a State class when several lifecycle methods, related resources, or a longer transition are easier to read together. Use a hook when one concern can be named and composed without hiding who owns it. Both use the same retained model; see State, identity, and ownership.

Adding, removing, or reordering hooks needs a process restart. An ordinary build reports a reordered or conditional hook instead of silently accepting the new identity.

Next

  • Build a task list — controllers, signals, and a computed value in one app, one lesson at a time.
  • Show shared state — observe a model that lives outside the widget.
  • Examples — the runnable companion apps.
Last updated on