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

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 wroteYou 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 / MultiProviderdelete — nothing to mount
ConsumerWidgetStatelessWidget
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 UILabel gets 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.

Open the finished code →