Flutter does not wrap the platform's buttons and switches. It paints its own, down to the last pixel, from one codebase — there is a Dart implementation of the iOS switch and a separate one of the Android switch. That decision sets the framework's shape. With no native view to hold anything for you, Flutter keeps three parallel trees: widgets describe, elements remember, render objects measure and paint. Almost every "why did it do that" — a lost scroll position, a widget that ignores its own width, a rebuild nobody asked for — is answered at one of those three layers. This guide is about which.
Each chapter opens with the short version. Tap one to read the detail.
Three trees: describe, remember, paint
~2 min
A widget is an immutable description that Flutter discards and rebuilds, potentially every frame. What persists is its element — one per widget, holding identity and State — and beneath that a render object does layout and painting. Building widgets is cheap because they are configuration, not the thing on screen.
"Everything is a widget" is the slogan; the three trees are the machine, and most confusion below resolves there.
Widgets are immutable declarations. You never mutate one — you return a new one and the framework compares old against new. That comparison happens against the element tree, built with one element per widget. Elements persist from frame to frame; widget objects are disposable. The split is what lets Flutter treat your whole tree as throwaway while keeping its underlying representation cached, walking only the parts that changed.
Below the elements sit render objects, which handle layout, painting and hit testing. Most widgets create none — they compose. Container is itself assembled from LimitedBox, ConstrainedBox, Align, Padding, DecoratedBox and Transform; centering means wrapping in Center rather than setting an alignment property.
The misconception worth killing first is that rebuilding is expensive so you should avoid it. The design says the opposite: build must be fast and free of side effects precisely so the framework can call it whenever needed, potentially once per rendered frame. Cost lives in layout and paint, not in constructing configuration objects.
class Counter extends StatefulWidget {
const Counter({super.key, required this.step}); // config — replaced each rebuild
final int step;
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _n = 0; // held by the element, survives rebuilds
void _bump() => setState(() => _n += widget.step);
}
Rule: before debugging, name the tree. A description is a widget, identity and state are an element, size and pixels are a render object — and the fix rarely lives in the layer you first suspected.
What survives a rebuild — State, and the keys that decide
~3 min
createState runs once per mounted element and its State outlives every widget instance after it. Whether that element is reused at all comes down to one comparison: same runtime type and equal key. With no key, type alone decides — so reordering stateful siblings silently leaves each State attached to the wrong item.
createState runs when the framework inflates your widget, and that State is permanently tied to one BuildContext. initState runs exactly once; didChangeDependencies runs immediately after it and again whenever an inherited widget you depend on changes; then build, any number of times; finally dispose, which is terminal — after it mounted is false and calling setState is an error.
Three documented rules get broken constantly. You may not call dependOnInheritedWidgetOfExactType from initState, so the lookups spelled Theme.of and MediaQuery.of belong in didChangeDependencies. The callback passed to setState is invoked synchronously and cannot be async. And build must have no side effects — calling showDialog from it throws "setState() or markNeedsBuild() called during build", while creating a future in it restarts that work on every rebuild, which is why FutureBuilder requires a future you made earlier.
didUpdateWidget is the step people skip. The parent rebuilt, the framework already swapped widget for the new instance, and hands you the old one — so anything derived from the previous configuration is re-derived here.
@override
void initState() {
super.initState();
widget.controller.addListener(_onChange); // subscribe
}
@override
void didUpdateWidget(Gauge old) {
super.didUpdateWidget(old);
if (old.controller != widget.controller) { // parent handed us a different one
old.controller.removeListener(_onChange);
widget.controller.addListener(_onChange);
}
}
@override
void dispose() {
widget.controller.removeListener(_onChange); // and release it
super.dispose();
}
Keys are the other half. Widget.canUpdate is two comparisons — same runtime type, equal keys — and if either fails the element is torn down, taking its State with it. In a list of same-typed rows with no keys, widgets match elements by position, so deleting the first row shifts every remaining State one slot up. Nothing errors; each row simply keeps someone else's expanded flag or half-finished animation.
// Matching happens among siblings, so the key belongs on the item's top widget.
ListView(children: [for (final t in todos) TodoRow(key: ValueKey(t.id), todo: t)]);
Two key mistakes to recognize: a UniqueKey() built inside build is equal only to itself, so it can never match last frame and the subtree is destroyed every frame; and a GlobalKey must be long-lived and owned by a State, since two live widgets sharing one assert at runtime.
Rule: read widget.x in build rather than caching it, and add a key the moment stateful siblings can be reordered, inserted or removed.
BuildContext is a position, and it is how shared data reaches down
~2 min
A BuildContext is a handle to one element's location in the tree, not a global object. Every lookup spelled X.of(context) walks upward from that position — and the context inside a build method sits above everything that method returns, which is the classic "no Scaffold found" failure.
The misconception to clear first is that context means "this widget, and everything I just built". It does not. Each widget has its own BuildContext, and that context becomes the parent of whatever build returns — so inside build you are holding a position above the tree you are writing. Return a Scaffold and then call Scaffold.of(context) with that same context and the search starts above the Scaffold, never finding it. Interpose a Builder: the context handed to its callback is the Builder's own, which sits underneath.
The same upward-only rule powers state sharing. InheritedWidget pushes data to a subtree: a descendant calls dependOnInheritedWidgetOfExactType, which both reads the value and registers a dependency, so that element rebuilds when the inherited widget changes — and updateShouldNotify decides whether a change counts. Theme and MediaQuery are ordinary uses of this, not special cases.
That gives you the honest split. State one widget owns — a selected tab, an animation's progress — is ephemeral, and a field plus setState is the complete answer; reaching for a package there is over-engineering. State several screens read and that should outlive a session — sign-in, preferences, a cart — is app state, and its home is an ancestor. Packages exist because hand-writing inherited widgets is tedious: provider wraps exactly this mechanism rather than replacing it.
One hazard: a context can go defunct while you await something, and once unmounted it never becomes mounted again — so check context.mounted after an await before navigating or showing anything.
Rule: ask where in the tree the reading happens. Everything of(context) can see is strictly above that point, so lifting state up is the only way to widen what a descendant can reach.
Constraints go down, sizes go up — and the errors when they don't
~3 min
Layout is one downward pass of constraints and one upward pass of sizes, with the parent placing the child. A widget cannot choose any size it likes and never knows its own position — which is why width: 100 so often yields something that is not 100 wide, and why most Flutter errors are really a missing constraint.
Memorize the rule: constraints go down, sizes go up, parent sets position. A constraint is four numbers — minimum and maximum width, minimum and maximum height. The parent hands each child its constraints, the child answers with a size within them, and the parent decides where to put it. Because this is a single pass, a widget cannot escape its constraints and cannot know its own position.
That answers the first question everyone asks. A Container(width: 100) directly under the screen receives a tight constraint — minimum equal to maximum — so it fills the screen and width is overruled. Wrapping it in Center works by loosening: Center converts the tight constraint it received into a loose one, minimum zero, so the child may finally be smaller.
Widgets fall into three habits: some try to be as large as possible (Center, ListView), some size to their child (Transform, Opacity), some want a particular size (Image, Text). Container switches habit depending on its arguments. Row and Column claim all their main-axis space unless mainAxisSize is minimum, and Expanded gives a child a share of what remains.
The sharp edge is an unbounded constraint, where a maximum is infinity — normal inside a flex box or a scrollable, and fatal to any widget that wants to be as large as possible, which throws in debug mode instead of rendering. Flutter's famous error messages are that story or its neighbor, a child nobody constrained:
•
"A RenderFlex overflowed by N pixels" — the yellow-and-black stripes. A Row did not constrain a child's width, so a long Text claimed what it wanted. Wrap it in Expanded or Flexible.
•
"Vertical viewport was given unbounded height" — a ListView inside a Column. The list expands to fill its scroll direction; the Column imposes no height; neither can go first.
•
"Incorrect use of ParentDataWidget" — Expanded and Flexible must sit directly inside a Row, Column or Flex, and Positioned inside a Stack. One wrapper in between breaks it.
Row(children: [
const Icon(Icons.message),
Expanded(child: Text(body)), // without this, long text overflows the Row
]);
A red screen carries these in debug and profile builds; a release build shows gray instead, so the console is where the message actually lives.
Rule: when a size surprises you, do not adjust the number — ask what constraint the parent passed down, and change that.
One isolate, one event loop, one frame budget
~2 min
Your app runs on a single isolate with a single event loop, and painting a frame is just another event in that queue. So await buys nothing against a slow computation: it releases the loop while waiting, but synchronous work longer than the frame gap stalls everything, the next frame included.
All Dart code runs in isolates — like threads, except each has its own memory and they share no state at all. By default a Flutter app does everything on one, the main isolate. Its event loop processes queued events one at a time, and a repaint request is one of those events, queued around sixty times a second on a 60 Hz device. Miss the deadline and the app janks.
Hence the sentence to remember: asynchrony is not parallelism. An async function runs synchronously up to its first await, and await releases the loop only while something else is pending. A 200 ms decode wrapped in as many awaits as you like still owns the isolate for 200 ms, and nothing paints meanwhile.
The fix is a second isolate. Isolate.run spawns one, runs your callback, returns its result and shuts down — concurrently, without blocking the main isolate.
Future<List<Photo>> parse(String jsonText) async {
return Isolate.run(() { // runs off the main isolate
final data = jsonDecode(jsonText) as List<Object?>;
return data.cast<Map<String, Object?>>().map(Photo.fromJson).toList();
});
}
Because isolates share nothing, messages between them are copied. A global variable mutated inside a spawned isolate is a different variable — the main isolate's copy is untouched — and objects holding native resources cannot be sent at all. Immutable values such as strings are the exception, passed by reference precisely because nobody can change them.
One Dart habit carries over from here. Dropping an await is not an error — you are handed the pending future itself, so the work runs out of order, and a try/catch around the call has already been left behind by the time it fails.
Rule: sort the slowness first. Waiting on something → await. Computing something → another isolate. Awaiting a computation fixes nothing.
What happens sixty times a second
~2 min
An AnimationController emits a new value every time the hardware wants a frame, so an animation turns your rebuild scope into a per-frame cost. Keeping the unchanging part of a subtree out of that loop — through a builder's child argument or a const instance — is most of animation performance.
An Animation knows nothing about the screen; it has a current value and a state. The AnimationController is the one tied to hardware, producing values from 0.0 to 1.0 across a duration, one per frame. You give it a vsync — usually your State with SingleTickerProviderStateMixin — so offscreen animations stop consuming resources, and you dispose it with the State. A Tween holds no state at all: it maps an input range onto an output range, and tween.animate(controller) joins the two. Wrap the controller in a CurvedAnimation to change the feel without touching the duration.
Now the cost. setState marks that State's whole subtree for rebuild, so state held higher than necessary rebuilds more than necessary. The traversal stops when it meets the same instance of a child as the previous frame — exactly what a const constructor guarantees, and why const is worth more than its tidiness suggests. Do not try to reproduce that by overriding == on a widget; the documented result is quadratic behavior.
AnimatedBuilder gives you the lever explicitly. Anything inside the builder is rebuilt every tick; anything passed as child is built once and handed back.
AnimatedBuilder(
animation: _controller,
child: const ExpensiveLogo(), // built once
builder: (context, child) => Transform.rotate(
angle: _controller.value * 2 * pi,
child: child, // reused every tick
),
);
At 60 Hz you have roughly 16 ms per frame, split between building and rendering — and you measure it in profile mode only, because debug builds are unoptimized and their numbers mean nothing. When the raster half is the slow one, suspect offscreen buffers: saveLayer allocates one and can force a render target switch, which mobile hardware particularly dislikes, and Opacity is the usual way to trigger it. Clipping is cheaper than its reputation — it does not call saveLayer unless you ask for anti-aliasing with one — but it is not free either.
Rule: rebuild the smallest subtree that changed, and hand everything that did not change to a child parameter.
A stack you push, or state you declare
~2 min
Navigator keeps a stack of routes, and push returns a future that completes with whatever pop passes back. Named routes look like the tidy option and are no longer recommended: their deep-link behavior cannot be customized and browser forward is unsupported.
Navigation is imperative by default. Navigator holds a stack of Route objects and shows the top one with the transition appropriate to the platform; you reach it through a route's BuildContext and call push or pop. MaterialPageRoute is the route type carrying Material transitions.
The detail people miss is that push returns a Future. It completes when that route is popped, carrying whatever value was passed — which is how a screen returns a result with no shared state at all.
final choice = await Navigator.of(context).push<Skill>(
MaterialPageRoute(builder: (context) => const PickSkill()),
);
if (!context.mounted) return; // the await may have outlived this element
Named routes are the part to unlearn early. They can carry deep links, but always the same way: an incoming link pushes a route regardless of where the user currently is, that behavior cannot be customized, and the browser's forward button is unsupported. The current recommendation is plain Navigator with MaterialPageRoute, or a routing package such as go_router when deep linking or the web address bar matters.
That is the real split. The imperative interface mutates a stack; the declarative one rebuilds the whole stack from application state, so the same link always produces the same screens instead of depending on what happened before.
For a shared visual element across a transition, wrap it on both routes in a Hero with an identical tag — matching tags are what trigger the animation, and one route must not contain two heroes with the same tag.
Rule: if a screen's identity is derivable from application state, declare it; reach for the imperative stack when the history genuinely is the state.
Touches are negotiated, not claimed
~2 min
Pointer events hit-test to the innermost widget and then bubble to the root, with no way to stop them. Above that, recognizers competing for the same touch negotiate: exactly one wins, or none does. That negotiation, not your callback, is usually why a tap "does nothing".
There are two layers. Underneath are raw pointer events — down, move, up, cancel. On a pointer down the framework hit-tests to find the widget at that location, dispatches to the innermost one, and the event then bubbles up every widget on the path to the root. There is no mechanism for canceling that dispatch, which is the first thing to unlearn if you arrive expecting to stop propagation.
Above sits the gesture layer, which turns pointer sequences into meaning. When several recognizers cover the same point — a tappable list row that also holds a tappable icon, or one detector configured for both tap and long press — they negotiate. Exactly one wins, or, if the movement turns out to be neither gesture, none of them does. A dead tap is therefore usually a question about which recognizer won, not about your handler.
GestureDetector(
onTap: _select,
behavior: HitTestBehavior.opaque, // default defers to the child: only where IT is hit
child: Padding(padding: const EdgeInsets.all(12), child: Text(label)),
);
GestureDetector recognizes rather than draws, so visible feedback is your job — or use InkWell, which requires a Material ancestor because that ancestor is where the ink is actually painted, and shows nothing without one. Many Material widgets already respond to gestures, so wrapping one in your own detector is a reliable way to start a fight nobody wins.
Text input works differently: a TextEditingController is the field's source of truth. Set its text and the field follows; type in the field and the controller notifies its listeners. It is a listenable object you own, so it must be disposed with the State — and writing to it from inside its own listener notifies you again.
Rule: when a gesture does nothing, look outward through the ancestors before touching the callback — something above you probably won the arena.
Three environments, each misleading in its own way
~3 min
Your code runs in three places that behave differently: a widget test whose clock only moves when you pump it, a debug build that is quick to iterate on and useless to measure, and a release build with the assertions and tooling stripped out. Knowing which one you are in is most of the debugging.
A widget test runs in a fake async zone, so time advances only when you say so. pumpWidget mounts a tree and renders one frame; pump triggers exactly one more, optionally after moving that fake clock. Interactions therefore do not repaint by themselves — tap and enterText dispatch the event, and the rebuild waits for your next pump. Asserting before pumping is the standard beginner failure and looks exactly like a broken widget.
testWidgets('increments', (tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // without this, nothing has rebuilt
expect(find.text('1'), findsOneWidget);
});
pumpAndSettle pumps repeatedly until no frames remain scheduled, which is how you wait out a transition — and an animation that repeats forever always has another frame scheduled, so the call cannot return and the test fails on its timeout. That is a correct report about your widget, not a flaky test. Above widget tests sit integration tests, which run the real app on a device and cannot touch native interface such as permission dialogs.
A debug build enables assertions and service extensions and compiles for fast development cycles rather than speed; emulators and simulators run this mode only. Hot reload lives here, injecting changed source into the running Dart runtime so the framework rebuilds the tree with state preserved. Two documented limits explain most "my change didn't apply" moments: global variables and static fields count as state and are not reinitialized, and changed native code needs a full stop and restart. A hot restart clears the first — it loads the changed code and restarts the app, losing your state; only rebuilding clears the second.
A release build disables assertions, strips debugging information and optimizes for start-up, execution and size — it is what ships, and it does not run on a simulator at all. Between the two sits profile, which keeps enough tracing to measure on a real device: that, not debug and not release, is where a timing means something.
Between all three sits pubspec.yaml, where an asset is invisible at runtime until declared under flutter: at exactly the right indentation — assets: two spaces in. An entry ending in a slash covers files directly in that directory and not its subdirectories.
flutter:
assets:
- assets/
- assets/icons/ # a separate entry: the line above does not reach here
Resolution variants are the exception, and automatic: put 2.0x/logo.png and 3.0x/logo.png beside logo.png and the closest match to the device's pixel ratio wins — a ratio of 2.7 picks the 3.0x file, rendered at the same logical size with more detail.
Rule: name the environment before you trust an observation — a debug timing, a test that never settles and a release-only crash are three different kinds of evidence.
Written by Keentune. We are not affiliated with or endorsed by the organizations whose documentation informs this guide, and any linked sources belong to their respective owners.
All exam, test, and product names and trademarks are the property of their respective owners and are used here for identification and reference only. Keentune is independent study practice — not affiliated with, authorized, or endorsed by any of these organizations.