Lesson 1 of 5
Stateless, Stateful and build()
"One must imagine Sisyphus happy." — Albert Camus, The Myth of Sisyphus (1942)
build() is the most-executed method you will ever write. It runs when state changes, when a parent rebuilds, when the screen rotates, when a theme changes, when a dependency updates — potentially sixty times a second, producing a description that is examined, compared, and thrown away, so that it can be produced again.
Sisyphus at least got to the top of the hill. Understanding that this is fine — that the repetition is the design rather than a cost the framework is apologising for — is most of what separates people who write comfortable Flutter from people who keep trying to prevent it.
Part I established the three trees and the rule that governs them. This Part is about the one you actually write in, and this chapter is about its two kinds and the method they share.
Flutter asks you to write build as a pure function — same inputs, same output, no side effects — and then provides no way to enforce it. You can start a network request in there, mutate a global, write a file; nothing complains, and it usually appears to work, because the first build happens when you expect. The discipline is entirely cultural, held up by convention and code review. Every framework built on a render-on-demand model has this problem and none has solved it convincingly. Is an unenforceable contract a design flaw, or simply an honest admission that the alternative — a language that can prove purity — costs more than it is worth here?
Two kinds, and the framework's own preference
The choice between them is usually taught as a rule of thumb. The framework's own usage is more interesting than the rule.
Edit here, then open the pad to run it on dartpad.dev.
A shop window and a shop. A window display is stateless: everything about it is determined by what was put there, and to change it you replace the arrangement. It holds nothing back and remembers nothing — hand it the same stock and the same instructions and you get the same window. The shop behind it is stateful: the till has a float, the stockroom knows what is left, the staff remember that a customer is coming back at four. That state is genuinely the shop's own, nobody outside needs to see it, and it persists while the window is redressed around it. The failure the analogy predicts is the one people actually make: putting something in the stockroom that the head office needs to know about. It is not that the stockroom is the wrong kind of place to keep things — it is that this particular thing has an audience beyond the shop, and keeping it there means inventing increasingly awkward ways for head office to phone up and ask.
setState is one line
The method at the centre of all this does dramatically less than its reputation suggests. Stripped of its debug assertions, State.setState in framework.dart is:
void setState(VoidCallback fn) {
assert(() { /* … debug-only checks … */ }());
_element!.markNeedsBuild();
}Your closure runs — synchronously, immediately, before setState returns — and then one element is marked dirty.
Edit here, then open the pad to run it on dartpad.dev.
The stateless/stateful split is inherited from React's function-versus-class components, and both frameworks have since travelled in opposite directions from the same starting point. React collapsed the distinction: hooks let a function component hold state, and class components became legacy, so the question "which kind is this?" largely stopped being asked. Flutter kept the split and has shown no sign of removing it, and the reason is the element tree. In React, hooks are stored against the component's position in the fibre tree, which is machinery React already had; Flutter's equivalent is the State object held by a StatefulElement, and it is a real object with a real lifecycle that you can put an initState and a dispose on. That turns out to matter enormously for a framework where so much state is a resource — an animation controller, a scroll position, a focus node, a stream subscription — each of which must be created once and released exactly once. React's model handles that with useEffect cleanups, which work and which nobody finds intuitive. Flutter's is more verbose and considerably harder to get wrong.
The lifecycle, in order
A State object has a documented life, and knowing the order tells you where each kind of work belongs.
Edit here, then open the pad to run it on dartpad.dev.
The framework's own stateless-to-stateful ratio caught me out. I had absorbed the standard advice — prefer stateless, reach for stateful only when you must — and assumed the framework would model it. It does the reverse: 296 stateful against 226 stateless. Then it became obvious. Almost every interesting framework widget owns a resource rather than a value — a scroll position, an animation controller, a focus node, a text editing controller — and a resource has a lifecycle, and a lifecycle needs initState and dispose. The advice to prefer stateless is good advice for application code and it is not a claim about which is better; it is a claim about where your state usually belongs, which is higher up than the widget wanting to display it. I had been holding a style preference as though it were a fact about the framework, and the count disproved it in about ten seconds. It is a small thing, but it is the third time in this volume that counting something has corrected a belief I did not know was a belief.
What a rebuild actually costs, measured
Part II claimed that const lets Flutter skip a subtree. Here is that claim measured with build counters in a real widget test rather than asserted.
Edit here, then open the pad to run it on dartpad.dev.
Five reasons build runs
setState is the one everybody knows and it is not the only trigger. Knowing the full list is what makes the "must be pure" rule feel necessary rather than pedantic:
setStateon this widget's ownState.- A parent rebuilt, so this widget was recreated as part of the parent's new description — this is the most common cause by a wide margin, and it is entirely outside this widget's control.
- An
InheritedWidgetit depends on changed — a theme, a locale, aMediaQuery. Rotating the device or opening the keyboard changesMediaQuery, which rebuilds everything that reads it. - A hot reload, which marks every element in the application dirty.
- An ancestor was rebuilt for any of the above, recursively.
Only the first is something the widget decided. The other four arrive unannounced, at moments the widget cannot predict, which is precisely why a side effect in build cannot be reasoned about — you are not writing "do this when the user taps", you are writing "do this whenever anything above me changes for any reason".
The keyboard case is the one that catches people, because it is so ordinary. Opening a keyboard changes the viewport insets, which changes MediaQuery, which rebuilds every widget that depends on it — so a request started in build fires again when the user taps a text field, and the causal chain between those two events is invisible in the code.
Narrowing what rebuilds
Given that a parent rebuilding is the dominant cause, the most effective performance technique in Flutter is not making build faster. It is arranging for less of the tree to be in it.
Three moves, in order of how often they apply:
Push state down. If only a small part of a screen depends on a changing value, extract that part into its own widget that holds the state. The counter that was in a big screen's State becomes a small StatefulWidget of its own, and now setState marks that element dirty rather than the screen's — so the rebuild covers ten widgets instead of two hundred. This is the single highest-leverage change available and it costs one class.
Make the unchanging parts const. Measured earlier: a const child of a parent rebuilt five times built once. Anything in a build method that does not depend on state or configuration should be const, and the linter will find them for you.
Lift the expensive child out as a parameter. When a widget must rebuild but contains a costly subtree that does not change, pass that subtree in rather than constructing it inside. A Builder-style widget that takes a child and rebuilds around it leaves the child object untouched, so the identity check succeeds and the subtree is skipped — this is why AnimatedBuilder takes a child argument and why using it correctly matters.
What these have in common is that none of them makes any code faster. They change what gets called, and that is almost always where the real win is, because the cheapest build method is the one that never runs.
Reaching state from outside, and why it usually hurts
Flutter does provide a mechanism for the case the decision table calls "lift it out": a GlobalKey gives you a handle to a specific element wherever it is in the tree, and GlobalKey<MyState> lets you call methods on that State directly.
It works, the framework uses it internally — Form and Scaffold both expose state this way — and in application code it is nearly always the wrong answer.
The cost is not performance, though a GlobalKey is more expensive than a local one. It is that you have created a dependency the tree does not express. A widget somewhere now reaches into a widget somewhere else and changes it, and nothing in either widget's signature says so. The compiler cannot see it, a reader cannot find it without searching for the key, and moving either widget can break it silently.
The times it is genuinely right are narrow and recognisable: validating a Form from a button outside it, or keeping a State alive while its widget moves to a different position in the tree — which only a GlobalKey can do. Everything else is usually a sign that the state wants to be above both widgets rather than inside one of them.
The lifecycle in a running widget
Edit here, then open the pad to run it on dartpad.dev.
Toggle the child and the console shows initState and dispose firing in pairs. That pairing is the contract: everything created in the first must be released in the second, and the framework guarantees dispose runs exactly once when the element is gone for good.
Notice what the collection-if is doing there. if (showChild) const _Tracked() does not hide the widget — it removes it from the description entirely, so no widget exists at that position, so the element is unmounted and dispose runs. Wrapping it in a Visibility or an Opacity instead would keep the element mounted and its state alive, which is sometimes exactly what you want and is a completely different behaviour arrived at by a similar-looking change.
That distinction is worth holding onto, because it is the practical form of the whole chapter. A widget removed from the tree loses its state; a widget merely made invisible does not. Neither is more correct — a form you want to preserve while a section is collapsed wants the second, and a screen you want to reset wants the first — but they look almost identical in the source, and the difference only shows up in behaviour the next person has to reverse-engineer.
There is a third option worth knowing for the case where you want to keep state and stop paying for the widget: Offstage, which keeps the element mounted and its state alive while skipping paint and hit-testing. It still takes part in layout, so it is not free, but it is the middle ground between "gone" and "drawn".
The mistake that reaches production most often is work in build, and it survives review because it usually appears to function. Starting a request there works on the first frame, and then a parent rebuilds for an unrelated reason and it fires again — so the symptom is not a broken screen but a server log with four times the traffic anyone expected, noticed weeks later by somebody else. The same shape produces animations that restart when the keyboard opens and analytics events that count a screen view every time the theme changes. The habit that prevents it is a question rather than a rule: before writing a line in build, ask whether it would be correct to run it sixty times a second, because that is the contract you are signing. Reading state, computing layout, constructing widgets — all fine. Anything that changes something, anywhere, belongs in initState, didUpdateWidget, or an event handler. The one genuinely awkward case is work that depends on an InheritedWidget, which cannot go in initState at all, and that is precisely what didChangeDependencies exists for.
Common Mistakes
- Doing work in
build. It runs constantly; anything with an effect belongs elsewhere. - Expecting
setStateto rebuild immediately. It marks the element dirty; the next frame rebuilds. - Thinking
setStateis asynchronous. The closure runs synchronously before it returns. - Mutating outside the closure. It works, and it severs the link between change and rebuild.
- Passing an
asyncclosure tosetState. It returns before the state changes; the framework asserts. - Using
initStatefor anything that can change. It runs once;didUpdateWidgetis the repair. - Reading an
InheritedWidgetininitState. UsedidChangeDependencies. - Forgetting
dispose. Every controller, subscription and timer created above must be released. - Making a widget stateful so others can reach its state. That means the state is in the wrong place.
- Assuming stateless is always preferable. The framework itself has more stateful widgets than stateless.
Interview Corner
StatefulWidget rather than a StatelessWidget?State from outside is the usual sign that the state is in the wrong place.setState actually do?_element!.markNeedsBuild(). It does not rebuild anything itself; the framework rebuilds dirty elements on the next frame. That is why mutating the field outside the closure and calling setState(() {}) behaves identically — the closure is not doing anything magic, it is documenting which change the rebuild is for. The one thing it genuinely rejects is an async closure, which the framework asserts against, because an async closure returns immediately and the element gets marked dirty before the state has actually changed.State lifecycle and what belongs where.createState makes the object, then initState runs once when the element mounts — subscriptions, controllers, anything created once. Then didChangeDependencies, which runs before the first build and again whenever an InheritedWidget it depends on changes; it exists because inherited lookups are not safe in initState. Then build, many times, which must be pure — no requests, no subscriptions, no mutation, because it can run on any frame for reasons outside the widget's control. didUpdateWidget fires when a new widget arrives as configuration for the same State, which is where anything derived from the old widget gets repaired — a State that only reads widget.x inside build is correct by construction, and one that caches from its widget usually is not. Finally dispose, once, the only moment that reliably means gone, where everything from initState is released. The through-line is that these are moments in the element's life, not the widget's — the widget is replaced constantly and the State is not, which is the whole reason the split exists.didChangeDependencies is separate.State object buys.build, leaked subscriptions, and how they show up.Hands-on
Model the decisions. Implement widgetKind, choosing stateless, stateful or "lift it out"; setStateEffect, reporting what the call actually does; lifecycleFor, placing a task in the right method; buildsAfter, predicting build counts with and without const; and needsDispose, deciding what must be released. The checks below run either way — a stub scores zero, so a passing run means the behaviour is real.
Edit here, then open the pad to run it on dartpad.dev.
Summary
buildis the most-executed method you write, and the repetition is the design rather than a cost.- The framework has more stateful widgets than stateless — 296 to 226 — because its widgets own resources.
- Prefer stateless in your own code not for cost but because state should live where fewest things see it.
- If something outside needs the state, neither answer is right. Lift it out.
setStateis one line:_element!.markNeedsBuild(). Everything else is debug assertions.- The closure runs synchronously, so mutating outside it works — and severs the link it exists to document.
- An
asyncclosure tosetStateasserts, because it returns before the state has changed. - Measured: a
constchild built once while the parent rebuilt five times; the non-const child built six. - The skip is the framework not calling
buildat all, so the saving scales with subtree size. initStateanddisposeare a pair. Everything created in one is released in the other.
Next: The Element Tree, and What Rebuild Means — what actually happens between markNeedsBuild and a pixel changing, and why a rebuild is cheaper than the word suggests.
The thing I keep returning to is that setState is one line, and that almost nobody believes it until they look. It has the reputation of a heavyweight operation — people avoid calling it, batch changes to reduce the number of calls, and worry about it in reviews — and what it does is set a flag. The expensive part is the rebuild, which happens later, once, for the frame, no matter how many times you marked the element. So the optimisation people reach for, calling it less, is optimising a boolean assignment. I find this a useful example of how performance folklore forms. Somebody once had a real problem that involved setState — almost certainly an expensive build method, which is a genuine issue — and the advice that survived was about the call rather than the cause, because the call is the visible part. The fix for an expensive rebuild is to make the rebuild cheaper or smaller, which is what const and keys and splitting widgets are for. Calling setState less often mostly means the screen is wrong for longer.
Q&A · 0
Enrol to ask questions and join the discussion.
No questions yet — be the first to ask.