Tutorial · 15 min · beginner
State, from setState up
One task list, three tools — setState for local state, signal + watch for shared stores, Provided for per-subtree values. No ProviderScope, no base classes.
What you'll build — running on device.
DartNative ships state management in the box, sized to how apps actually
grow: setState for what’s local, signal fields on plain classes for
what’s shared, Provided<T> for what belongs to a subtree. No root
wrappers, no code generation, no base classes to extend. You’ll build a
two-screen demo — a basics screen that walks each primitive, then a
task-list screen where they combine into the Store pattern. Both screens
are the playground’s own state demos, carried into the tutorial
byte-for-byte.
What you need
- A project from Your first DartNative app
Step 1 — setState, and where it stops
Start with the tool you already know. A StatefulWidget owns a value and
setState rebuilds it — exactly as in Flutter. (Inline example — you won’t
find this one in the demo files: every widget in them is deliberately a
StatelessWidget, which is the whole point of what follows.)
class _TapCounterState extends State<_TapCounter> {
int _taps = 0; // private to this State — nobody else can read it
// in build():
Button(
variant: ButtonVariant.bordered,
title: 'Tap (setState)',
onPressed: () => setState(() => _taps++),
),
This is the right tool while exactly one widget cares about the value —
a text field’s focus, an expanded/collapsed flag, a draft nobody has
submitted yet. Its limit is structural: _taps is private to that State
object, so the moment a second widget needs to display it, you’d be
threading callbacks and constructor parameters. That’s the cue to move the
value out of the widget — which is what signals are for.
Step 2 — signal + watch: shared values, no ceremony
A signal is an observable box around a value. It lives outside the widget tree — a top-level variable, a field on a class, wherever plain Dart lets you put it:
/// Counter — incremented by a button, displayed by two separate widgets.
final _counter = signal<int>(0);
/// Derived value — recomputes automatically when [_counter] changes.
final _counterLabel = computed<String>(() {
final n = _counter.value;
if (n == 0) return 'Press + to start';
if (n == 1) return 'Tapped once';
return 'Tapped $n times';
});
computed is the derived form: it reads other signals inside its callback,
and that read is the subscription — _counterLabel recomputes whenever
_counter changes, with no listener wiring.
On the widget side, .watch(context) subscribes the building element and
returns the current value:
// .watch() subscribes and returns the current value.
// When _counter changes, only this widget rebuilds.
final count = _counter.watch(context);
final label = _counterLabel.watch(context);
Note what the counter section is: a plain StatelessWidget. No
StatefulWidget, no Consumer, no ConsumerWidget — the signal drives
the rebuild, and only the widgets that watch it rebuild.
Writing is just as direct — assign .value, or update when the new value
derives from the old:
_counter.value++;
_counter.update((c) => c - 1);
The demo’s dark-mode panel is the same idea with a signal<bool> and a
Switch(onChanged: (v) => _darkMode.value = v) — flip it and only the
panel repaints.
Step 3 — Side-effects and existing code
Two escape hatches round out the basics. effect() runs a callback
immediately and re-runs it whenever any signal it read changes — for
side-effects that aren’t widgets (logging, persistence, analytics):
_stopLogging = effect(() {
// Reads _counter.value → registers as a dependency.
dnLog('[StateDemo] counter changed → ${_counter.value}');
});
It returns a stop function; call it to unsubscribe.
And if you already have a ChangeNotifier, you don’t rewrite it —
Listenable.watch bridges it into the same rebuild mechanism, unchanged:
// Existing ChangeNotifier — unchanged.
final hist = _history.watch<_ClickHistory>(context);
// Reads hist.entries → rebuilt on notifyListeners()
Step 4 — The store: a plain class with signals
The second screen scales the idea up. A store is nothing but a plain Dart class whose fields are signals and whose methods are the actions:
/// Feature store — holds state + actions for one task list.
class TaskStore {
/// The task list — the source of truth for this store.
final tasks = signal<List<Task>>(const []);
/// Derived stat: done count. Updates automatically.
late final doneCount = computed<int>(
() => tasks.value.where((t) => (t as _TaskData)._done).length,
);
void addTask(String title) {
if (title.trim().isEmpty) return;
final id = 'task-${_nextId++}';
tasks.update((list) => [...list, _TaskData(id: id, title: title.trim())]);
}
}
No base class, no registry, no ref. At the root, a singleton holder — the
AppRepository pattern — owns the stores and any app-wide signals:
class AppStore {
static final workTasks = TaskStore('Work');
static final personalTasks = TaskStore('Personal');
/// App-wide theme mode — a bare top-level signal on the holder.
static final themeMode = signal<String>(
playgroundPalette.brightness == Brightness.dark ? 'dark' : 'light');
}
(playgroundPalette is the demo UI kit’s palette — the signal just seeds
itself from whatever brightness the kit starts in.)
The screen’s theme toggle is one line —
AppStore.themeMode.update((m) => m == 'dark' ? 'light' : 'dark') — and
every widget watching themeMode restyles itself. Still no setState
anywhere on this screen.
Step 5 — Provided: per-subtree values
The screen shows two task panels — Work and Personal — built from the
same widget class. How does each panel know which store is “its” store,
without a constructor parameter threaded through every layer? Provided<T>
scopes a value to a subtree:
// Each _TaskListPanel wraps its store in Provided<TaskStore>
// so its children can read it without prop-drilling.
Provided<TaskStore>(
value: AppStore.workTasks,
child: const _TaskListPanel(),
),
const SizedBox(height: 16),
Provided<TaskStore>(
value: AppStore.personalTasks,
child: const _TaskListPanel(),
),
Anywhere below the wrap, Provided.of<T> reads the nearest one back —
then watching its signals works as before:
// Reads the nearest Provided<TaskStore> ancestor.
final store = Provided.of<TaskStore>(context);
final tasks = store.tasks.watch(context);
final done = store.doneCount.watch(context);
final total = store.totalCount.watch(context);
_TaskListPanel and every _TaskRow inside it take no store parameter at
all — the subtree is the parameter. Provided is the
dependency-injection half of Provider/Riverpod’s job, without the framework
half, which signals already cover.
Coming from Provider or Riverpod?
| You wrote | You write now |
|---|---|
context.watch<T>() / ref.watch(p) | store.field.watch(context) |
context.read<T>() / ref.read(p.notifier) | store (a plain reference) |
StateProvider<int> | final n = signal<int>(0) |
ProviderScope / MultiProvider | delete — nothing to mount |
ConsumerWidget | StatelessWidget |
ProviderScope(overrides: [...]) | Provided<T>(value:, child:) |
Why this is native
The state layer itself is plain Dart — no framework magic, nothing to mount at the root. The native part is what happens after a signal changes: the reconciler diffs the rebuilt widgets and patches the affected native views in place — a changed count means one
UILabelgets new text, not a subtree repaint. State updates are as granular as the views they touch, which is why store-driven screens stay smooth even mid-scroll.
The finished code
In the public repo — dn create ., dn run. The two screens under
lib/screens/ are byte-identical copies of the playground’s state demos
(plus its shared UI kit, also verbatim); lib/main.dart adds only a thin
two-row launcher. When the playground screens improve, this tutorial
inherits it by copying the files again.