May 18, 2025 · 3 min read
Flutter Fundamentals: Widgets, State, and the Three Trees
Your UI is a function of your state. A tour of the fundamentals — widgets, StatefulWidget, the three trees, keys, and BuildContext — that make the rest of Flutter click.
Flutter's core idea is radical in its simplicity: your UI is a function of your state. You describe what the screen should look like for the current state, and the framework figures out the minimal changes needed to get the pixels there. Understand a few fundamentals and the rest of the framework clicks into place.
Everything is a widget
Layout, styling, gestures, even padding — all widgets. Widgets are immutable descriptions; they're cheap to create and throw away.
class Greeting extends StatelessWidget {
const Greeting({super.key, required this.name});
final String name;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text('Hello, $name', style: Theme.of(context).textTheme.titleLarge),
);
}
}
Note the const constructor. A const widget is built once and reused, letting Flutter skip rebuilding that subtree entirely. Make widgets const wherever the inputs are compile-time constants — it's the cheapest performance win in the framework.
Stateless vs. Stateful
A StatelessWidget is pure: same inputs, same output. A StatefulWidget holds mutable state that can change over the widget's life.
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
void _increment() => setState(() => _count++);
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: _increment,
child: Text('Count: $_count'),
);
}
}
setState marks the element dirty and schedules a rebuild. The State object survives rebuilds; the widget instance does not.
The three trees
Flutter keeps three parallel trees:
- Widget tree — your immutable configuration, rebuilt often.
- Element tree — the mutable bridge that holds state and maps widgets to render objects.
- Render tree — does layout, painting, and hit-testing.
When you rebuild, Flutter diffs the new widget tree against the elements and updates only what changed. This is why rebuilding a widget is cheap — you're not rebuilding the render objects.
Keys: preserving identity
When widgets of the same type reorder, Flutter needs help matching old state to new position. That's what keys are for.
// Without keys, reordering these could attach the wrong state to the wrong row.
ListView(
children: [
for (final todo in todos)
TodoTile(key: ValueKey(todo.id), todo: todo),
],
);
Reach for keys when you have a list of stateful widgets that can reorder, insert, or delete.
BuildContext is a handle into the tree
context is your element's location in the tree. It's how you look up for inherited data:
final theme = Theme.of(context);
final width = MediaQuery.sizeOf(context).width;
A common bug: using a context after an async gap when the widget may have been disposed. Guard it:
Future<void> _save() async {
await repository.save();
if (!mounted) return; // the State was disposed while we awaited
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Saved')));
}
Lift state out as it grows
setState is perfect for local, ephemeral state (a toggle, a text field). Once state is shared across screens or outlives a widget, move it into a state-management solution — Bloc, Riverpod, or Provider — and keep your widgets thin. The widgets stay a pure function of state; the state lives somewhere it can be tested in isolation.