Skip to content

Widgets & state

A Fleury UI is a tree of widgets: immutable descriptions of what should be on screen. You never mutate a widget — you describe a new one and let the framework work out the minimal change. (If you’ve used Flutter, this is the same model, painting to a grid of cells instead of pixels; everything below will feel like home.)

A StatelessWidget describes UI from its inputs and any state it reads through its build context. It has no companion mutable State object. Override one method, build, which returns what the widget displays:

class Greeting extends StatelessWidget {
const Greeting(this.name, {super.key});
final String name;
@override
Widget build(BuildContext context) => Text('Hello, $name');
}

A StatefulWidget also carries mutable state that survives rebuilds — a counter, a scroll position, a text buffer. The widget itself is still immutable; the mutable part lives in a companion State object that the framework keeps alive across rebuilds:

local_counter.dart
You write · editable
class LocalCounter extends StatefulWidget {
const LocalCounter({super.key});
@override
State<LocalCounter> createState() => _LocalCounterState();
}
class _LocalCounterState extends State<LocalCounter> {
int count = 0;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Count: $count'),
Button(text: 'Increment', onPressed: () => setState(() => count++)),
],
);
}
Live preview
Live · interactive
click & type to interact ⓘ how this demo runs

When a field in your State changes, use setState to rebuild its widget:

setState(() => count++);

setState runs your callback synchronously, then marks this widget for rebuild. On the next frame the framework re-runs the dirty build path, performs the layout and paint work that change requires, and diffs the new cell grid against the old. The terminal presenter writes only cells that actually changed; when the runtime has no frame work, it skips build, layout, paint, and presentation.

For fields owned by your State, use setState to schedule the rebuild. Shared models publish changes through Notifier.notify(); subscribe with NotifierBuilder or context.listen(model) when your UI reads their state.

The framework owns your State object’s life. The methods you’ll override most, in the order they fire:

  • initState() — once, when the state is first inserted. Set up controllers, start subscriptions. Always call super.initState().
  • didChangeDependencies() — right after initState, and again whenever an inherited dependency you read (a Theme, a MediaQuery) changes. Not called for a plain setState.
  • build(context) — whenever this state is marked dirty, an inherited dependency changes, or its parent supplies updated configuration. Keep it pure: no side effects, just describe the tree.
  • didUpdateWidget(oldWidget) — when the parent rebuilds and hands this state a new widget instance of the same type. Compare widget to oldWidget and react: if the parent passed a different stream or notifier, unsubscribe from the old one and subscribe to the new. (Callbacks need nothing here; read widget.onChanged when you call it.)
  • dispose() — once, when the widget is removed for good. Tear down anything you started in initState — controllers, tickers, stream subscriptions. Always call super.dispose().

A clock starts its timer in initState and cancels it in dispose:

clock.dart
You write · editable
class Clock extends StatefulWidget {
const Clock({super.key});
@override
State<Clock> createState() => _ClockState();
}
class _ClockState extends State<Clock> {
late final Timer _timer;
DateTime _now = DateTime.now();
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
setState(() => _now = DateTime.now());
});
}
@override
void dispose() {
_timer.cancel(); // started in initState → cleaned up here
super.dispose();
}
@override
Widget build(BuildContext context) => Text('$_now'.substring(0, 19));
}
Live preview
Live

Three more hooks cover rarer cases. deactivate() runs when the state leaves the tree, and activate() runs if it’s reinserted in the same frame, which happens when a widget with a GlobalKey moves to a new parent. reassemble() runs after a hot reload, for recomputing cached values; see Hot reload.

Inside a State you also have three getters: widget (the current configuration), context (this widget’s location in the tree), and mounted (whether the state is still in the tree — guard async callbacks with if (!mounted) return; before calling setState).

The BuildContext handed to build is a handle to where this widget sits in the tree. Use it to read values provided by ancestors:

final theme = Theme.of(context); // nearest ThemeData
final size = MediaQuery.sizeOf(context); // terminal size, in cells

These walk up the tree to find the nearest ancestor that provides the value, and they subscribe this widget to it — change the theme and every widget that read Theme.of(context) rebuilds. That’s the mechanism behind theming and responsive layout; it’s a Scope under the hood (see below). There are shorthands too: context.theme and context.colors.

For application state, context.scope<Model>() finds the nearest Scope<Model> and subscribes this widget until it leaves the tree. If you already have a model, context.listen(model) subscribes directly and returns that same object; that subscription lasts while the widget’s builds keep reading the model. Call these readers during this widget’s build, and use the captured model in event callbacks. Both subscriptions end automatically when the widget unmounts.

Note one difference from a render tree: a BuildContext has no .size. A widget doesn’t know its own dimensions during build (it hasn’t been laid out yet). Read the screen size from MediaQuery, and make a subtree adapt to its space with layout widgets like Expanded and Wrap (see Layout).

With value and onChanged, your state owns the value. The control asks for a change, and you supply the updated value. Here region is a String field on your State:

Select<String>(
value: region,
options: const [
SelectOption(value: 'us-east', label: 'US East'),
SelectOption(value: 'eu-west', label: 'EU West'),
],
onChanged: (value) => setState(() => region = value),
)

The select always shows region. Picking an option calls onChanged, and the new choice appears only after your setState stores it. The Select reference has a live example.

A controller holds live state that both your code and the widget can change. Create it once in State and dispose it with that state:

final list = ListController(initialIndex: 24);
@override
void dispose() {
list.dispose();
super.dispose();
}

The task browser starts at task 25. Arrow keys and the Go to 25 button update the same list.currentIndex; ordinary rebuilds preserve it.

An initial* widget argument seeds internal state once. For example, NumberInput(initialValue: 42) keeps the user’s edits when its parent rebuilds. Use a controller for later programmatic changes, or a new key for a deliberate reset. Supply a seed or a controller for the same value, not both.

Input onChanged callbacks report edits from the user and edits made through the semantic tree, such as a test’s fill or an agent setting the field’s value. Programmatic controller writes notify controller listeners, so updating a model does not echo through an input callback. Replacing a controller adopts the new one’s state; omitting it creates fresh internally owned state.

When the framework rebuilds, it reuses existing State objects by matching each new widget to the old one at the same position with the same type. Usually that’s exactly right and you pass no key. You reach for a Key when identity needs to survive reordering — most often a list whose items get inserted, removed, or shuffled:

  • ValueKey(item.id) — ties a widget’s identity to a stable value, so its state follows it when the list reorders. The common case.
  • UniqueKey() — equal only to itself; use it to force a fresh state (a remount) where you’d otherwise get reuse.
  • GlobalKey() — unique across the whole tree; lets you reach a widget’s State from elsewhere via key.currentState. Powerful but heavier — prefer lifting state up before reaching for one.

Use Scope(value, child: ...) to share an existing object with descendants, or Scope.create(Model.new, child: ...) to create and own a model for a subtree. Descendants choose context.scope<Model>() or a ScopeBuilder<Model> consumer. The type argument is the key, and the nearest scope of that type wins. Plain values notify when replaced by an unequal value; notifiers also publish changes themselves. The State management guide shows complete examples of both readers. See the Scope reference for creation and disposal rules.

The built-ins you’ve met (Theme, MediaQuery, and DefaultTextStyle) use scopes behind their .of(context) helpers. Give your own scope a distinct value type when it needs an independent identity in the tree.


Next: where the tree starts running — App entry points. For arranging widgets once you have them, see Layout; for the leaf widgets that go in the tree, the widget reference.