Flutter's widget system looks deceptively simple on the surface — everything is a widget, you compose them, and the UI appears. But once you go deeper, you realize Flutter made a very deliberate architectural decision by splitting widgets into two kinds: Stateless and Stateful. Understanding why that split exists, and how the machinery behind it works, is what separates developers who use Flutter from developers who truly understand it.
Before distinguishing the two, it's worth being precise about what a widget actually is in Flutter's model.
A widget is an immutable description of a piece of UI. That's it. It's a configuration object — a blueprint. It doesn't draw anything, it doesn't hold layout state, it doesn't live on screen. It just describes what should exist.
Flutter maintains three separate trees to actually make that description come alive:
- Widget tree — The lightweight, immutable descriptions you write in code. Rebuilt frequently.
- Element tree — The long-lived, mutable counterpart to each widget. Elements persist across rebuilds and act as the glue between widgets and render objects.
- RenderObject tree — The heavy, layout-and-paint layer. This is what actually measures, positions, and renders pixels.
This separation is critical to Flutter's performance. Rebuilding the widget tree is cheap because widgets are just Dart objects with a few fields. The element and render object trees, which are expensive to create, are preserved and updated incrementally.
A StatelessWidget is a widget whose entire output is determined solely by its constructor arguments. Given the same inputs, it will always produce the same widget tree — there is no internal state that can change between builds.
class GreetingCard extends StatelessWidget {
final String name;
final String message;
const GreetingCard({super.key, required this.name, required this.message});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Hello, $name'),
Text(message),
],
);
}
}
Flutter calls build() on a stateless widget when its parent rebuilds and passes it new configuration. The widget itself has no lifecycle beyond that single build() call — no initialization, no teardown, no reactive hooks.
The const Optimization
Stateless widgets shine when paired with const constructors. When Flutter encounters a const widget in a rebuild, it can short-circuit the entire subtree — it knows the widget is identical to the previous one (same type, same arguments, canonicalized at compile time), so there's nothing to diff.
// Flutter will never rebuild this subtree unless the parent explicitly changes it
const Icon(Icons.star, color: Colors.amber, size: 24);
For UI elements that are truly static — icons, labels, decorative containers — const is a free performance win. Make it a habit to use const wherever possible and let the Dart analyzer enforce it with the prefer_const_constructors lint.
A StatefulWidget is for UI that needs to change over time based on user interaction, async results, animations, or any other runtime event. The key design decision Flutter made here is that the widget and its state are two separate objects.
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: () => setState(() => _count++),
child: Text('Count: $_count'),
);
}
}
Why separate them? Because widgets are immutable and short-lived, but state needs to persist. When Flutter rebuilds the Counter widget, it creates a brand-new Counter object — but the _CounterState instance that holds _count is kept alive by the element tree. The element simply calls widget.build() on the new widget description while retaining the existing State object beneath it.
The Stateful Lifecycle
State objects have a full lifecycle managed by the Flutter framework. Understanding each phase is essential for writing correct, leak-free Flutter code.
createState()
Called on the widget (not the state) to create the associated State object. This happens exactly once — when the widget is first inserted into the tree.
@override
State<MyWidget> createState() => _MyWidgetState();
initState()
The first method called on the State object after it's created. This is where you perform one-time initialization — subscribing to streams, setting up animation controllers, making initial API calls.
@override
void initState() {
super.initState(); // Always call super first
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
_fetchInitialData();
}
initState() runs before the first build, and context is available but restricted here — you cannot call context-dependent operations like Navigator.of(context) from initState(). Use didChangeDependencies() or a post-frame callback for that.
didChangeDependencies()
Called immediately after initState(), and again whenever an InheritedWidget that this state object depends on changes. For example, if your widget reads from Theme.of(context) or a custom InheritedWidget, and that data changes, didChangeDependencies() fires before the next build.
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Safe to use context-dependent APIs here
final locale = Localizations.localeOf(context);
_updateForLocale(locale);
}
build()
The only required override. Called every time the state is marked dirty — after setState(), after didChangeDependencies(), after didUpdateWidget(), and during the initial render. Must be pure and free of side effects.
setState()
The mechanism for signaling that internal state has changed and the widget needs to rebuild.
setState(() {
_count++;
_isLoading = false;
});
When you call setState(), Flutter marks the element as dirty. It doesn't trigger an immediate rebuild — instead, it schedules a frame. At the start of the next frame, Flutter walks the element tree, finds all dirty elements, and calls build() on each of them. The widget returned by build() is then reconciled against the previous widget using the element tree (more on this below).
Called when the parent rebuilds and passes a new widget instance to this state's element. The old widget is passed as an argument, giving you a chance to react to configuration changes.
@override
void didUpdateWidget(covariant MyWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.userId != oldWidget.userId) {
_fetchDataForUser(widget.userId);
}
}
This is the correct place to respond to external prop changes — not initState(), which only runs once.
deactivate()
Called when the State object is temporarily removed from the tree — for example, when a widget is moved to a different subtree location using a GlobalKey. This is uncommon in everyday Flutter code, but important to know.
dispose()
Called when the State object is permanently removed from the tree. This is where you clean up resources — cancel timers, close streams, dispose animation controllers, remove listeners.
@override
void dispose() {
_controller.dispose();
_scrollController.dispose();
_subscription.cancel();
super.dispose(); // Always call super last
}
Forgetting to dispose() resources is one of the most common sources of memory leaks in Flutter apps.
How Reconciliation Actually Works
When a dirty element rebuilds, it gets a new widget from build(). Flutter then needs to decide: does the existing child element still apply, or does it need to be replaced?
Flutter checks two things in order:
- Type — Is the new widget the same runtime type as the widget the element currently holds?
- Key — If a key is present, does it match?
If both match, Flutter reuses the element and simply updates its widget reference. The underlying RenderObject is updated in place — expensive layout state is preserved.
If either differs, Flutter deactivates the old element, creates a new one, and mounts it. For stateful widgets, this means the State is disposed and a new one is created from scratch.
// Scenario: parent conditionally renders one of two widgets
if (_showA)
const WidgetA() // Type: WidgetA
else
const WidgetB() // Type: WidgetB — new element, old state discarded
Keys: Making Reconciliation Precise
By default, Flutter reconciles children by type and position. For static, fixed-size lists this works perfectly. But for dynamic, reorderable, or filtered lists, positional matching breaks down.
// Imagine these items get reordered from [A, B] to [B, A]
// Without keys, Flutter sees: position 0 = same type, position 1 = same type
// It reuses the same elements — the state of A bleeds into B's position
Column(
children: [
TodoItem(label: 'B'),
TodoItem(label: 'A'),
],
)
Keys tell Flutter explicitly which element corresponds to which widget, regardless of position.
LocalKey
Scoped to siblings within the same parent. The common variants are:
ValueKey<T> — keyed by a value (most common, use with IDs or strings)
ObjectKey — keyed by object identity
UniqueKey — generates a new key every time (forces recreation — use deliberately)
ListView(
children: _todos.map((todo) =>
TodoItem(key: ValueKey(todo.id), label: todo.label)
).toList(),
)
Now when items reorder, Flutter correctly matches each TodoItem to its element by ID, preserving the right state in the right place.
GlobalKey
Unique across the entire widget tree. A GlobalKey lets you access a widget's State, BuildContext, or RenderObject from anywhere in the app.
final _formKey = GlobalKey<FormState>();
// Later:
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
}
GlobalKey is powerful but expensive — Flutter maintains a global registry for them. Avoid using them as a shortcut to reach into widget state; prefer proper state management patterns for that. Use GlobalKey when you genuinely need framework-level access — form validation, imperative scroll control, or moving a subtree across the tree while preserving its state.
The mounted Property and Async Safety
Every State object has a mounted boolean. It becomes true after initState() runs and transitions to false after dispose(). It exists to solve a specific and common problem: calling setState() after the widget has been removed from the tree.
Future<void> _loadData() async {
final result = await repository.fetchItems(); // async gap — widget may be disposed here
setState(() => _items = result); // throws if widget was already disposed
}
The safe pattern:
Future<void> _loadData() async {
final result = await repository.fetchItems();
if (mounted) {
setState(() => _items = result);
}
}
This is especially important in scenarios where a user navigates away while a network call is still in flight, or when a parent removes the widget before the async operation completes. The mounted check is a lightweight guard that prevents both the exception and the logical error of mutating dead state.
When to Use Which
The choice is usually straightforward once you internalize the rule:
If the widget can fully describe its UI from its constructor arguments alone, use StatelessWidget. If it needs to track any internal state that changes over time, use StatefulWidget.
Some practical heuristics:
- Pure display widgets (cards, labels, icons, decorative layouts) →
StatelessWidget with const
- Anything with user interaction that changes appearance (toggles, counters, form fields) →
StatefulWidget
- Animations →
StatefulWidget with SingleTickerProviderStateMixin
- Widgets that only receive state from above (via constructor or
InheritedWidget) and just render it → StatelessWidget
It's also worth noting that the separation isn't just about correctness — it's about communicating intent. A StatelessWidget signals to every reader of your code: this widget has no internal moving parts. That's a strong, useful guarantee.
Putting It All Together
Flutter's architecture makes more sense when you see the whole picture at once:
- Widgets are cheap, immutable blueprints. Rebuilt frequently, thrown away without ceremony.
- Elements are the persistent, mutable objects that hold the tree together. They match incoming widgets to existing state.
- RenderObjects are expensive and preserved. Updated in place whenever possible.
- Stateful widgets get a separate
State object, anchored to an element, that survives widget rebuilds.
setState() marks elements dirty and schedules a frame — not an immediate rebuild.
- Reconciliation by type and key determines whether to reuse or replace elements.
- Keys make that reconciliation correct when position alone isn't enough.
mounted is your safety net for async operations that outlive the widget.
Every Flutter performance tip — minimizing rebuilds, using const, lifting state appropriately, keying lists — traces back to understanding these fundamentals. Once the model is clear, the best practices follow naturally.