A study companion · Flutter for people who already ship native apps
Stop mutating views. Describe them instead.
On iOS and Android you build a screen once, keep references to its views, and reach back in to change them. Flutter takes that habit away — and gives you something better in return. The whole framework turns on one idea, so let's start with it, using code you could have written last week.
// iOS — you hold the views and poke at them
func updateBadge() {
countLabel.text = "\(count)"
badge.isHidden = count == 0
if count > 0 {
checkoutButton.isEnabled = true
checkoutButton.alpha = 1.0
} else {
checkoutButton.isEnabled = false
checkoutButton.alpha = 0.4
}
}
Every place the data can change, you must remember to call something like this — and touch each view by hand. Forget one branch and the screen quietly lies about the data. Guess what Flutter does instead before you continue.
// Flutter — describe the screen for the current count
Widget build(BuildContext context) {
return Column(
children: [
Text('$count'),
if (count > 0)
CheckoutButton(onPressed: _checkout),
],
);
}
You never reach into a label to set its text. You write a function that returns a
of the whole screen for the current value of
count — and you let Flutter render it. No references, no branches that toggle
individual properties.
But if build only describes, how does the screen ever change? You change
the data and tell Flutter. It calls build again, compares the new description to
the old one, and updates only what actually differs on screen:
void _addItem() {
setState(() {
count++; // 1. change the data
}); // 2. setState schedules a rebuild
} // 3. Flutter diffs & repaints the delta
One call. Mutate state, call setState, and Flutter
re-runs your description and paints the difference — efficiently, whether one pixel changed or
the whole layout did. That loop is the idea the rest of this page is built around.
Part 1 · Foundations — §1 of 4
Describe, don't mutate
Objective
Understand the one difference that makes Flutter feel foreign at first — and why, once it clicks, most of your UI bugs stop being possible.
You have spent years building a mental model where a view exists, you keep a handle to it, and
you change it over time. A UILabel or a TextView is an object that
lives on screen; you set its .text and the pixels follow. This is imperative
UI: a sequence of commands that mutate a long-lived tree of view objects.
Flutter is declarative. You write a function that, given the current data, returns a description of what the screen should look like right now. You never hold a description and edit it later — when the data changes, you throw the old description away and build a fresh one. The framework figures out the minimal set of real changes to make on screen.
Native views (imperative)
The view tree is the source of truth. Your data and your views can drift apart, and keeping them in sync is your job — the classic source of "the label still shows the old value".
Flutter (declarative)
Your data is the source of truth. The UI is a pure function of it: UI = build(state).
The screen cannot drift, because it is re-derived from the data every time.
If you have written SwiftUI or Jetpack Compose, you already know this feeling — Flutter is the same family of idea, and everything you learned there transfers. If you have not, the adjustment is real but short. The single sentence to hold onto: you describe the UI as a function of your data, and you change the UI only by changing the data.
The habit to unlearn
Your fingers will want to find a widget and change it: "grab the label, set its text." That
reflex will fight you for about a week. There is no label to grab. There is only the next call
to build, which will produce a new Text with the new value. When you
feel the urge to mutate a widget, change a variable and rebuild instead.
Learn more — "rebuild everything" sounds slow. Why isn't it?
Rebuilding does not mean repainting. Your build method returns cheap,
immutable description objects (widgets) — allocating them is fast, like building a small
struct. Flutter then compares that new description to the previous one and computes the
minimal set of changes to the actual on-screen objects (the render tree). A Text
whose string didn't change costs almost nothing; only the parts that genuinely differ are
re-laid-out and repainted.
This is exactly the trick that makes React, SwiftUI and Compose fast too. You get the simplicity of "re-describe the whole screen" with the performance of "only touch what changed" — because those are two different trees, which is the subject of §3.
Check yourself
In a declarative framework like Flutter, how do you change what's on screen?
You change the data and rebuild. Widgets are immutable descriptions — there is nothing to reach into and mutate (A, D). And you don't repaint by hand (C); Flutter diffs the new description against the old and repaints only the difference.
Part 1 · Foundations — §2 of 4
Everything is a widget
Objective
See that Flutter has essentially one building block, and that UI is built by nesting it — composition, not inheritance and not layout files.
On native platforms you have a zoo of concepts: views, view controllers, layout constraints, storyboards or XML, and a separate world for padding and centring. Flutter collapses almost all of it into a single noun: the widget. A button is a widget. So is the text on it. So is the padding around it, the centre alignment, the row it sits in, and the whole screen.
Crucially, structure and styling that you think of as properties on other platforms are
widgets here. You don't set a padding attribute — you wrap your widget in a Padding
widget. You don't set an alignment flag — you wrap it in Center. UI is built by
composition: small widgets nested inside bigger ones, forming a tree.
Center( // centres its child
child: Padding( // adds space around its child
padding: EdgeInsets.all(16),
child: Text( // draws a string
'Hello, Flutter',
style: TextStyle(fontSize: 24),
),
),
)
Read that as a tree: Center contains Padding contains Text.
Each widget has exactly one job. On iOS or Android you might express "centred, padded text" with
constraints or a layout file; in Flutter you express it by nesting three single-purpose widgets.
This feels verbose at first and becomes strangely pleasant: everything is explicit and local.
There are two flavours of widget, and the distinction runs through the whole framework: (a description that never changes once built — an icon, a label, a static row) and StatefulWidget (a widget that keeps some mutable data and can rebuild itself when that data changes — a checkbox, a form field, an animating spinner). Modules 1 and 2 are dedicated to each; for now just know both exist.
Learn more — where do styling and theming live, then?
Some styling is a parameter you pass (like TextStyle above). App-wide styling —
colours, fonts, component defaults — lives in a Theme, which is itself a widget
near the top of the tree. Descendant widgets read from it with Theme.of(context).
So even "the app's design system" is just a widget wrapping the rest of the tree. Module 10
covers this properly.
Check yourself
You want to add 16 logical pixels of space around a button. In Flutter you would…
Spacing is a widget, not a property. You compose a Padding around the button. Most buttons have no margin property (A), Flutter doesn't use constraint files (B), and you compose rather than subclass (D).
Which best describes how a Flutter screen is constructed?
Composition: a tree of small widgets. Flutter has no controllers of note (B), no inflated markup (C), and Flutter draws its own widgets rather than positioning platform controls (D) — which is why an app looks identical on both platforms.
Part 1 · Foundations — §3 of 4
The three trees
Objective
Learn the one piece of internal machinery worth knowing early: how Flutter can throw away descriptions on every build yet keep your state and stay fast.
Here is the apparent paradox. Widgets are immutable and get rebuilt constantly. Yet a text field keeps what you typed, a scroll list keeps its position, and an animation doesn't restart on every frame. If the widgets are thrown away, where does that memory live?
The answer is that "the UI" is not one tree but three, and they play different roles. You only ever write the first one directly.
Tree one
Widgets — the blueprint
Immutable, cheap, disposable. What your build methods return. Rebuilt freely.
Think of a widget as a configuration: "a red text saying hello". It describes; it does
not persist.
Tree two
Elements — the living tree
Long-lived. For each widget currently on screen there is one Element that holds
its place in the tree, links to parent and children, and — for stateful widgets — owns the
State object. This is what BuildContext actually is.
Tree three
Render objects — the pixels
Also long-lived. These do layout, painting and hit-testing. You rarely touch them directly. Flutter mutates these in place when a rebuild produces a different description.
The flow: you produce a new tree. Flutter walks it against the existing element tree, and for each position asks a cheap question — "is the new widget the same kind as the old one here?" If yes, it keeps the element (and its State, and its render object) and just updates them with the new configuration. If no, it tears that element down and builds a fresh one. Your typed text survives because its element survived, even though the widget describing it was replaced.
You will not manipulate elements or render objects for a long time — possibly ever. But this model pays off the moment something surprises you: a form that mysteriously resets, an animation that jumps, a list item showing the wrong data after reordering. Every one of those is Flutter matching the wrong element to a widget. The fix is a key, and it will make sense because you know these trees exist (module 11).
Learn more — so what exactly is the context in build(context)?
BuildContext is the element. When Flutter calls your build method it
hands you a handle to this widget's slot in the live element tree. That's why
context can answer questions about location — "what's the theme here?",
"what's the nearest Navigator above me?" — via calls like
Theme.of(context). It's a pointer into tree two.
This also explains a classic beginner error: using a context to look up something
that lives above a widget from inside that same widget's build, before
it's mounted. The element isn't in the tree yet, so there's nothing to walk up to. We'll meet
this concretely in module 4.
Check yourself
You type into a TextField, then an unrelated setState higher up rebuilds the screen. Why doesn't your text vanish?
The new widget is the same kind in the same position, so Flutter keeps the existing element and the State it owns, just updating it with the new config. Widgets don't store your text (B), and rebuilds don't skip widgets for those reasons (A, C).
Part 1 · Foundations — §4 of 4
Dart in a hurry
Objective
Pick up just enough Dart to read every snippet on this page, by mapping it onto the Swift or Kotlin you already know.
Dart will feel immediately familiar — it sits squarely between Swift and Kotlin. Curly braces,
classes, async/await, sound null safety. You can read it on day one; here
are the handful of things worth pointing at explicitly.
| Dart | Swift / Kotlin | Note |
|---|---|---|
var x = 3; | var | Type inferred, reassignable. |
final x = 3; | let / val | Set once. Your default. |
const x = 3; | static let-ish | Compile-time constant. Matters a lot for widgets — see below. |
String? | String? | Nullable. Non-nullable by default, like both. |
x?.foo, x! | same | Null-aware access; ! asserts non-null. |
late | lateinit (Kotlin) | "I'll set this before first use, trust me." |
Future<T> | async fn / Deferred | A value arriving later. await it. |
Stream<T> | AsyncSequence / Flow | Many values over time. |
Two Dart-specific things earn their keep. First, named parameters in braces. Most widget constructors take named arguments, often required ones — this is why widget code reads like a labelled form rather than a positional guessing game:
class Badge {
final String label;
final int count;
Badge({required this.label, this.count = 0});
}
// call site — order-free, self-documenting
Badge(label: 'Inbox', count: 12);
Badge(label: 'Drafts'); // count defaults to 0
Second, const constructors. If a widget's configuration is fully known
at compile time, you can construct it with const. Flutter then knows that widget can
never differ between builds and skips rebuilding its subtree entirely. Sprinkling const
on your static widgets is the cheapest performance win in the framework — the analyzer will even nag
you to add it.
No const
Icon(Icons.star)
// rebuilt & re-compared
// every single build
With const
const Icon(Icons.star)
// built once, reused,
// subtree rebuild skipped
Learn more — arrow functions, cascades, and trailing commas
=> is shorthand for a one-expression function body:
int square(int x) => x * x;. You'll see it constantly for short callbacks like
onPressed: () => print('tap').
The cascade .. calls several methods on the same object without
repeating it: controller..forward()..repeat(). Handy but optional.
The trailing comma after the last argument isn't noise — Dart's formatter uses
it to decide whether to keep a call on one line or expand it vertically, one argument per line.
In deeply nested widget trees, always leave the trailing comma; it makes dart format
lay the tree out readably. This is a real convention, not a matter of taste.
Check yourself
Why prefer const Text('Hi') over Text('Hi') when the text is fixed?
A compile-time-constant widget can't change between builds, so Flutter reuses the instance and skips rebuilding it — a free performance win. Non-const widgets compile fine (B); it has nothing to do with selection (A) or rendering pipeline (D).
Part 2 · Module 1 of 12 · gentle
Your first widget: StatelessWidget
Objective
Write a widget that renders purely from the data passed into it, and understand exactly when its build runs.
A StatelessWidget is a widget whose appearance depends only on the configuration it
was given. Give it the same inputs and it always looks the same; it has no memory of its own.
Think of a component that shows a user's name and avatar — hand it a User, it draws
that user, done. Most of the widgets you write will be stateless.
class Greeting extends StatelessWidget {
final String name;
const Greeting({super.key, required this.name});
@override
Widget build(BuildContext context) {
return Text('Hello, $name');
}
}
// used like any other widget
const Greeting(name: 'Ada')
Three things to notice. The data (name) is final — a stateless widget's
fields never change; to show a different name you build a new Greeting. The
constructor is const, so this widget is cheap. And build is the one method
that matters: it returns a description, and Flutter calls it whenever this widget is inserted or its
parent hands it new configuration.
Compare it to a native custom view. There, you'd have an initialiser, outlets, and an
update() you call by hand when the model changes. Here there is no update method and no
outlets — just build, re-run for you. The mental weight of "did I refresh every view
after the model changed?" is simply gone.
Don't do work in build
build can run many times per second (during animations, scrolling, parent rebuilds).
It must be cheap and free of side effects: no network calls, no writing to disk, no starting
timers. Think of it as "draw the current data", nothing more. Where do those things go?
That's module 5.
Glossary
- StatelessWidget
- A widget with no mutable state; its look is a pure function of its constructor arguments.
- build()
- The method returning this widget's description. Called by the framework, never by you.
- @override
- An annotation confirming you're replacing a superclass method — Dart warns if you aren't.
- super.key
- Passes an optional identity key up to the base class. Ignore it until module 11.
Exercise
A price tag widget
Write a stateless PriceTag widget that takes a double price and a
String currency, and renders a Text like "$12.50 USD".
Make it const-constructible.
class PriceTag extends StatelessWidget {
final double price;
final String currency;
const PriceTag({
super.key,
required this.price,
required this.currency,
});
@override
Widget build(BuildContext context) {
return Text('\$${price.toStringAsFixed(2)} $currency');
}
}
toStringAsFixed(2) forces two decimals. Everything is final and the
constructor is const — textbook stateless widget.
Check yourself
When does Flutter call a StatelessWidget's build method?
It can run many times — on insertion and every time a parent rebuild hands it fresh config. Not once (A), never by your hand (C), and not on a timer (D). This is why build must stay cheap.
Part 2 · Module 2 of 12 · core
State that changes: StatefulWidget
Objective
Hold data that changes over time, mutate it correctly with setState, and understand why the widget and its State are two separate classes.
When a widget must remember something between builds — a counter, whether a switch is on, the text
in a field — it becomes a StatefulWidget. This comes as a pair of classes, which trips
everyone up for a minute, so let's be precise about why.
The StatefulWidget itself is still immutable and disposable, exactly like a stateless
one — it's rebuilt constantly. The mutable data can't live there. So it lives in a separate,
long-lived State object, which Flutter keeps attached to the element (tree
two, from §3) across rebuilds. The widget is the blueprint; the State is the memory.
class Counter extends StatefulWidget {
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _count = 0; // the mutable memory
void _increment() {
setState(() { // tell Flutter state changed
_count++;
});
}
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: _increment,
child: Text('Count: $_count'),
);
}
}
The critical rule: mutate state only inside setState. Writing
_count++ on its own changes the variable but never tells the framework to rebuild, so
the screen freezes on the old value — the number-one beginner bug. setState does two
things: it runs the closure (where you change the data) and it marks this element as needing a
rebuild, so Flutter re-runs build before the next frame.
Why the two-class split, again? Because the widget is thrown away and rebuilt, but your
_count must survive that. Putting it in State, which Flutter pins to the
durable element, is what lets the number persist while the Counter widget describing
it is replaced on every frame.
Glossary
- StatefulWidget
- An immutable widget that spawns a companion
Stateobject to hold mutable data. - State<T>
- The long-lived object holding mutable fields and the
buildmethod for a stateful widget. - createState()
- Called once by Flutter to make the State object when the widget is first mounted.
- setState()
- Run a closure that mutates state, then flag this element to rebuild. The only sanctioned way to change state.
Exercise
A toggle
Turn a stateless "favourite" icon into a stateful one: tapping it flips between a filled and an
outlined heart. Sketch the State class and its build.
class _FavState extends State<Fav> {
bool _on = false;
@override
Widget build(BuildContext context) {
return IconButton(
icon: Icon(_on ? Icons.favorite
: Icons.favorite_border),
onPressed: () => setState(() => _on = !_on),
);
}
}
The ternary in build chooses the icon from the current state — no imperative
"swap the image" call anywhere. Flip the bool, rebuild, done.
Check yourself
You write _count++; in a handler, without wrapping it in setState. What happens?
It compiles and the data does change — but nothing tells Flutter to rebuild, so build never re-runs and the UI is stale. It's not a compile error or crash (B, D); the data, not the screen, is what's correct (opposite of C).
Why is mutable state kept in a separate State object rather than on the StatefulWidget itself?
Widgets are disposable descriptions; the State is pinned to the durable element so your data survives rebuilds. Dart doesn't forbid mutable widget fields (B) — the convention exists precisely because widgets shouldn't hold changing data. It's not about sharing (A) or isolates (C).
Part 2 · Module 3 of 12 · the tricky one
Layout: Row, Column, and constraints
Objective
Learn Flutter's whole layout model in one sentence, then use it to place widgets — and to read the overflow error you will hit.
Flutter has no auto-layout, no constraint solver, no flexbox engine to reason about. Its layout is a single pass with one rule, repeated down and up the tree: constraints go down, sizes go up, and the parent sets position. Memorise that sentence; it explains every layout you'll ever build.
Concretely: a parent tells each child the minimum and maximum width and height it may take ("you may be 0–360 wide and 0–800 tall"). The child picks its own size within that, and tells the parent. The parent then decides where to put the child. A widget never chooses its own position and never sees anything outside its own constraints.
The two workhorses are Row (lay children out horizontally) and Column
(vertically). They come from the same family as flexbox, with two alignment axes:
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, // along the row
crossAxisAlignment: CrossAxisAlignment.center, // across it
children: [
const Icon(Icons.menu),
const Text('Inbox'),
Icon(Icons.search),
],
)
When a child should absorb the leftover space, wrap it in Expanded (take all you can)
or Flexible (take up to what you need). A Row with a fixed icon and an
Expanded title is the bread and butter of app bars.
Now the rite of passage. Put a very long Text, or a fixed-width box, in a
Row that isn't wide enough, and you meet this:
Read it the way you now read a compiler error. A Row tried to place children wider than
the width it was handed. The Row can't grow (its parent didn't allow it), so it reports the overflow
rather than silently clipping. The fix is always one of: let a child flex (Expanded),
let the content wrap or shrink, or make the parent scrollable. Overflow isn't a bug in Flutter — it's
Flutter refusing to guess what you meant.
Learn more — the two "unbounded constraints" traps
Some parents impose unbounded constraints on an axis — a Column gives its
children unlimited height, a horizontal ListView gives unlimited width. Put a widget
that also wants to be "as big as allowed" (another ListView, an Expanded
in the wrong place) inside one, and Flutter throws "unbounded constraints", because infinity isn't
a size anything can render to.
The cure is to introduce a bound: wrap the inner widget in a SizedBox with a fixed
height, or an Expanded, so it has an actual number to work with. When you hit this,
ask "who, above me, forgot to say how big I'm allowed to be?" — that's where the fix goes.
Glossary
- Constraints
- Min/max width and height a parent hands a child. The child must size itself within them.
- main / cross axis
- For a Row, main is horizontal; for a Column, vertical. Cross is the other one.
- Expanded
- Wrap a child so it fills the free space along the main axis.
- SizedBox
- A box of an exact size — used for fixed dimensions or as fixed-size spacing.
- RenderFlex overflow
- A Row/Column whose children exceed its size. Solved with flex, wrapping, or scrolling.
Exercise
A safe list tile
Build a Row with a leading icon, a title that can be arbitrarily long, and a trailing
chevron — such that a long title truncates with "…" instead of overflowing. Which widget wraps the
title?
Row(
children: [
const Icon(Icons.folder),
const SizedBox(width: 12),
Expanded( // take the leftover width
child: Text(
title,
overflow: TextOverflow.ellipsis, // truncate with …
),
),
const Icon(Icons.chevron_right),
],
)
Expanded gives the title a bounded width; ellipsis tells it to truncate
rather than demand more room. Icons stay their natural size on the ends.
Check yourself
Flutter's layout algorithm, in one line, is:
That's the whole model, and a single pass. There's no global solver (A), widgets never see the screen or place themselves (B), and siblings don't talk to each other — only parent and child do (D).
You see "A RenderFlex overflowed by 24 pixels". The most likely fix is to…
Overflow means children want more space than the axis allows; give one a way to flex, wrap, or scroll. It's not a font issue (A), rebuilding doesn't change the sizes (C), and you can't catch a layout assertion into working layout (D).
Part 2 · Module 4 of 12 · core
BuildContext and reading down the tree
Objective
Use context to reach shared data that lives above a widget — theme, media size, navigator — and understand the .of(context) pattern.
From §3 you know context is a handle to this widget's spot in the element tree. Its
main day-to-day use is looking upward: "what theme applies here?", "how tall is the
screen?", "which navigator should I push onto?". Flutter answers by walking up the element tree from
your context until it finds the nearest ancestor of the right kind. That's the
SomeThing.of(context) idiom.
Widget build(BuildContext context) {
final theme = Theme.of(context); // nearest Theme above
final size = MediaQuery.of(context).size; // screen metrics
return Container(
width: size.width * 0.9,
color: theme.colorScheme.surface,
child: Text('Adapts to theme & screen',
style: theme.textTheme.titleMedium),
);
}
This is Flutter's answer to dependency lookup: instead of threading the theme through every
constructor, you put it once near the top (inside a MaterialApp, which you'll see in
module 10) and any descendant reads it via context. The mechanism underneath is the
InheritedWidget — a widget that exposes data efficiently to everything below it, and
rebuilds only the widgets that actually read it when the data changes. You rarely write one by hand
early on, but Theme, MediaQuery, and most state-management libraries are
built on it.
The "no ancestor found" error
Calling SomeThing.of(context) with a context that sits above the
thing you're looking for throws "No SomeThing found in context". The classic case: opening a dialog
or snackbar using the context from the build that created the
Scaffold, rather than a context below it. The fix is to use a context from a child
widget — often by extracting the child into its own widget, so its context is genuinely
beneath the ancestor.
Glossary
- BuildContext
- A reference to a widget's location in the element tree; the argument to
build. - .of(context)
- Idiom to fetch the nearest ancestor of a given type (Theme, MediaQuery, Navigator…).
- InheritedWidget
- A widget that shares data with its whole subtree efficiently; the engine behind
.of. - MediaQuery
- Ancestor exposing screen size, orientation, text-scale, safe-area insets, and more.
Check yourself
What does Theme.of(context) actually do under the hood?
It walks upward from your position via context to the closest Theme. That's why it's location-sensitive — a global singleton (B) or a full scan (C) wouldn't be. It reads an existing widget, it doesn't rebuild the theme (D).
Part 2 · Module 5 of 12 · core
The State lifecycle & handling input
Objective
Know where to start work (subscriptions, controllers, timers) and where to clean it up — the native viewDidLoad/onDestroy equivalents.
build is for describing, not for doing. So the side-effecting work — subscribing to a
stream, creating an animation controller, starting a timer — belongs in the State
lifecycle methods, which mirror the native ones you already use.
| Flutter | iOS / Android | Use it to… |
|---|---|---|
initState() | viewDidLoad / onCreate | Set up controllers, subscriptions. Runs once. |
didChangeDependencies() | — | React when an inherited dependency (theme, locale) changes. |
build() | viewWillLayoutSubviews-ish | Describe UI. Often. Keep it pure. |
didUpdateWidget() | — | The parent rebuilt with new config; reconcile against old. |
dispose() | deinit / onDestroy | Cancel subscriptions, dispose controllers. Runs once. |
The pairing that matters most is initState/dispose. Anything you create in
the first you must tear down in the second, or you leak. Text fields make this concrete: they use a
TextEditingController, which you own.
class _SearchState extends State<Search> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController();
}
@override
void dispose() {
_controller.dispose(); // or you leak the controller
super.dispose();
}
@override
Widget build(BuildContext context) =>
TextField(controller: _controller);
}
Note late final: the controller is set exactly once, in initState, not at
the declaration — because you want it created when the State mounts, not before. Also note that both
initState and dispose call super; forgetting that is a common
early slip.
Glossary
- initState()
- First method called on a State. Set up long-lived resources here.
- dispose()
- Last method called. Release everything you created in
initState. - TextEditingController
- Owns the text and selection of a field. You create and dispose it.
- controller
- General pattern: an object you own that drives a widget (text, scroll, animation, tabs).
Exercise
Spot the leak
A teammate subscribes to a location Stream in initState with
_sub = stream.listen(...), but the widget causes battery drain long after it leaves
the screen. What did they forget, and where does it go?
They never cancelled the subscription. It must be cancelled in dispose:
@override
void dispose() {
_sub.cancel(); // stop listening when we leave
super.dispose();
}
Every resource created in initState needs a matching teardown in
dispose. Streams, controllers, timers, listeners — all of them.
Check yourself
Where should you create an AnimationController or subscribe to a stream?
initState runs once when the State mounts — the right home for setup — paired with dispose for teardown. In build you'd recreate it constantly (A). The widget's constructor runs on every rebuild and holds no mutable state (C); globals leak and don't scope to the screen (D).
Part 2 · Module 6 of 12 · core
Lists that scroll: ListView.builder
Objective
Render long, efficient, scrolling lists the way Flutter intends — the direct analogue of UITableView / RecyclerView.
You could build a list by dumping every item into a Column inside a
SingleChildScrollView. For ten items, fine. For ten thousand, you'd build ten thousand
widgets up front and run out of memory. The native answer is cell recycling; Flutter's is
lazy building.
ListView.builder takes an item count and a builder callback, and only calls the builder
for items near the viewport — building them as they scroll into view and letting off-screen ones go.
No delegates, no reuse identifiers, no dequeue. Just a function from index to widget.
ListView.builder(
itemCount: messages.length,
itemBuilder: (BuildContext context, int i) {
final m = messages[i];
return ListTile(
leading: CircleAvatar(child: Text(m.initials)),
title: Text(m.sender),
subtitle: Text(m.preview),
onTap: () => _open(m),
);
},
)
Compare the mental overhead. A UITableView needs a data source, cell registration, and
careful reuse to avoid showing stale content in recycled cells. Here the builder is a pure function
of the index — there's no recycled cell to accidentally show the wrong data, because each visible
item is described fresh. The performance is equivalent; the footguns are gone.
Learn more — separators, slivers, and infinite scroll
ListView.separated adds a builder for the dividers between items.
For headers that pin, parallax effects, or grids and lists that share one scroll view, you
graduate to slivers (CustomScrollView with
SliverList, SliverAppBar). Slivers are the low-level scrollable
protocol; ListView is a friendly wrapper over one.
Infinite scroll is just: watch a ScrollController, and when you near the bottom,
fetch the next page and setState with the longer list. No special API needed.
Glossary
- ListView.builder
- A lazily-built scrolling list; calls its builder only for items near the screen.
- itemBuilder
- Callback from an index to the widget for that row.
- ListTile
- A ready-made row with leading/title/subtitle/trailing slots and a tap handler.
- Sliver
- A lower-level scrollable region; the building block of advanced scroll effects.
Check yourself
Why prefer ListView.builder over a Column of all items in a scroll view for a 5,000-row list?
Lazy building keeps memory and startup cost proportional to what's visible, not to the whole list. A Column can go in a scroll view (A) — it's just eager. There's no disk caching (B), and taps work anywhere (C).
Part 2 · Module 7 of 12 · core
Navigation: pushing and popping screens
Objective
Move between screens with the Navigator stack, pass data forward, and get a result back — the analogue of pushViewController / starting an Activity for a result.
A screen in Flutter is called a route, and routes live on a stack managed by the
Navigator. It's exactly the push/pop model you know from
UINavigationController and the Android back stack. Pushing a route slides a new screen
on top; popping removes it and reveals the one beneath.
// go to a detail screen, passing data in via its constructor
final picked = await Navigator.push<Color>(
context,
MaterialPageRoute(
builder: (context) => ColorPickerScreen(title: 'Pick'),
),
);
// back on the first screen, `picked` is whatever the second popped with
if (picked != null) setState(() => _color = picked);
Navigator.pop(context, Colors.teal); // pops & returns teal
Two things stand out for a native developer. First, you pass data into a screen the obvious
way — as constructor arguments to its widget — not through a segue or an Intent extra. Second,
getting a result back is just awaiting the push: the future completes when
the pushed route pops, with whatever value it popped. No delegate protocol, no
onActivityResult. It reads like a function call that happens to draw a screen.
Learn more — named routes and the case for a router package
You can register named routes (Navigator.pushNamed(context, '/settings')) for a
central table of destinations. But for anything with deep links, web URLs, or nested navigation,
the community standard is a declarative router package such as go_router, which
maps URL patterns to screens and handles the browser's address bar on Flutter web. Learn the raw
Navigator first — go_router sits on top of it — then adopt a router when
your navigation grows real structure.
Glossary
- Route
- One screen on the navigation stack.
- Navigator
- The widget managing the route stack; you call
pushandpopon it. - MaterialPageRoute
- A route with the platform's standard screen transition.
- pop result
- The value passed to
pop, delivered to whoeverawaited thepush.
Check yourself
How do you get a value back from a screen you pushed (like a picked date)?
push returns a Future that resolves when the route pops, carrying the pop value. Flutter needs no delegate protocol (A), no shared globals (B), and has no onActivityResult (D) — the async return replaces all of that.
Part 2 · Module 8 of 12 · trickier
Async UI: Future, Stream, and builders
Objective
Render UI that depends on data arriving later — a network fetch, a live stream — without manually juggling loading and error states.
Most screens show something that isn't ready yet: a profile being fetched, a price ticking over a socket. Because your UI is a function of your data, the question becomes "what's the description while the data is still loading, or if it failed?" Flutter answers with builder widgets that hand you the current async status and let you return a widget for each case.
FutureBuilder<User>(
future: _fetchUser(id), // a Future<User>
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const CircularProgressIndicator(); // loading
}
if (snapshot.hasError) {
return Text('Failed: ${snapshot.error}'); // error
}
return Text(snapshot.data!.name); // success
},
)
The snapshot is a little bundle describing "where is this async operation right now" —
still waiting, done with data, or done with an error. You branch on it and return the appropriate
widget. There's no manual "hide the spinner, show the content" toggling of view visibility; you
describe all three states declaratively and Flutter shows whichever matches.
For a sequence of values over time — a websocket, sensor readings, a database query that re-emits on
change — use StreamBuilder with a Stream. Same shape; it just rebuilds each
time a new value arrives.
Don't create the future in build
Passing future: _fetchUser(id) directly in build means a new
fetch starts on every rebuild — every parent rebuild refires your network call. Create the future
once in initState and store it in a field, then pass that field to the
FutureBuilder. (This is why some teams skip the builders for a state-management
solution — module 9.)
Glossary
- Future<T>
- A single value that arrives later — a network response, a file read.
- Stream<T>
- A sequence of values over time — socket messages, location updates.
- FutureBuilder
- Rebuilds based on a Future's state: waiting, done-with-data, or error.
- snapshot
- The object describing an async operation's current status and value/error.
Exercise
Three states, one widget
You have a Stream<int> of live temperatures. Describe a StreamBuilder
that shows a spinner until the first reading, then the temperature, and handles error. Which
builder, and what do you branch on?
StreamBuilder<int>(
stream: _temperatures,
builder: (context, snap) {
if (snap.hasError) return const Text('—');
if (!snap.hasData) return const CircularProgressIndicator();
return Text('${snap.data}°C');
},
)
hasData is false until the first value, so the spinner covers the initial wait; each
new emission rebuilds with the latest reading. Store _temperatures in a field, not
inline.
Check yourself
You put future: fetch() inline in build and the network call fires over and over. Why?
build runs often, and each run calls fetch() afresh, so a new request starts each time. The fix is to create the Future once (in initState) and hold it in a field. It's not auto-retry (B), GC (C), or looping (D).
Part 2 · Module 9 of 12 · trickier
State beyond one widget
Objective
Share state across many widgets without threading it through every constructor — and know how the ecosystem's solutions relate, so the library zoo stops being intimidating.
setState is perfect for state a single widget owns. But real apps have state many
screens care about: the signed-in user, the cart, a theme choice. Passing it down by constructor
through ten layers ("prop drilling") gets miserable fast. You need a way to lift state up and
let interested widgets read it from anywhere below.
The techniques form a ladder, and every rung is the same core idea — put the state above the widgets that need it, and expose it through the tree:
Rung 1
Lift state up
Move the state into the nearest common ancestor and pass values down plus callbacks up. No libraries. The right first move, and often enough.
Rung 2
InheritedWidget
The built-in mechanism (from module 4) to expose data to a whole subtree efficiently. Powerful, a little verbose to write by hand — which is why libraries wrap it.
Rung 3
A package
Provider, Riverpod, Bloc. Each is ergonomics over the same idea: hold state somewhere central, rebuild only the widgets that read the parts that changed.
Don't reach for a big library on day one. Build a screen or two with setState and lifted
state; feel where it hurts. The pain — "I'm passing this callback through five widgets that don't
care about it" — is what the packages remove, and you'll appreciate them far more once you've felt
the problem they solve. When you do choose one, Riverpod is the common modern
default; Bloc suits teams that want a strict, event-driven structure.
Learn more — the one rule that keeps state manageable
Whatever tool you use, keep a clean split between ephemeral UI state and
app state. Ephemeral state — the current tab, whether a dropdown is open, an
animation's progress — belongs in a local State with setState; no
library needed and none wanted. App state — anything more than one screen reads, or that should
outlive a screen — is what you lift and share.
Most "which state management should I use?" agonising evaporates once you sort each piece of state into one of those two buckets. Ephemeral stays local; shared goes up. The library only governs the second bucket.
Check yourself
A checkbox's "is it ticked" state is read by nothing but the checkbox itself. Where should it live?
That's ephemeral, single-widget state — local State with setState is exactly right, and reaching for a global tool (A, B) or persistence (D) is overkill that adds coupling for nothing. Lift state up only when something else needs to read it.
Part 2 · Module 10 of 12 · gentle
Material, Cupertino & theming
Objective
Scaffold a real screen with the Material widgets, understand what MaterialApp and Scaffold give you, and theme the whole app from one place.
Flutter draws every pixel itself, so it ships two complete design systems as widget libraries: Material (Google's, the default, works on both platforms) and Cupertino (Apple's look). Most teams build in Material for both platforms; you reach for Cupertino widgets when you specifically want iOS-native styling.
Two widgets anchor almost every Material app. MaterialApp sits at the very top and
provides theming, navigation, and localisation to everything below. Scaffold gives a
single screen its standard furniture: an app bar, a body, a floating action button, a drawer,
bottom navigation.
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.indigo, // whole palette from one colour
),
useMaterial3: true,
),
home: Scaffold(
appBar: AppBar(title: const Text('Inbox')),
body: const MessageList(),
floatingActionButton: FloatingActionButton(
onPressed: _compose,
child: const Icon(Icons.edit),
),
),
)
Note ColorScheme.fromSeed: give it one seed colour and Material 3 derives a full,
accessible light-and-dark palette. Because every Material widget reads its colours from the theme via
Theme.of(context) (module 4), changing that seed restyles the entire app — buttons,
app bars, switches, all of it — from a single line. That's the theming payoff of "everything reads
from an ancestor".
Glossary
- MaterialApp
- Root widget wiring up theme, navigation and localisation for a Material app.
- Scaffold
- The layout for one screen: app bar, body, FAB, drawer, bottom bar.
- ThemeData
- The app's design tokens — colours, typography, component styling.
- Cupertino
- The widget set styled to match iOS, for when you want Apple's native look.
Check yourself
You change the seedColor in ThemeData and the whole app restyles. Why does one line reach everything?
Widgets don't hardcode colours — they look up the nearest Theme through context, so replacing it re-colours them all. It's not recompilation (A), a global (C), or polling (D); it's the same inherited-data mechanism from module 4.
Part 2 · Module 11 of 12 · the subtle one
Keys: when identity matters
Objective
Understand the one situation where Flutter's element-matching guesses wrong, and how a Key tells it which state belongs to which widget.
Recall §3: when rebuilding, Flutter matches each new widget to an existing element by position and
type, and preserves the element's State when they match. That heuristic is right almost
always — and wrong in one specific, memorable case: reordering or removing items in a list of
stateful widgets of the same type.
Picture three TodoTiles, each stateful (say each holds a half-typed edit). You delete
the first. The list now has two widgets; Flutter matches them by position to the first two
elements — so the second tile's in-progress edit stays attached to the first position. The
widgets moved but the state didn't follow, because position was the only thing Flutter had to match
on. Visually: the wrong row keeps the wrong state.
No keys — state sticks to position
Column(children: [
TodoTile(todo: a),
TodoTile(todo: b),
])
// reorder → State stays put,
// data swaps under it. Bug.
Keys — state follows identity
Column(children: [
TodoTile(key: ValueKey(a.id), todo: a),
TodoTile(key: ValueKey(b.id), todo: b),
])
// reorder → State follows its key.
// Correct.
A Key gives a widget a stable identity independent of position. With
ValueKey(item.id), Flutter matches new widgets to old elements by key first, so
each tile's state travels with its data no matter how the list is reordered. The rule of thumb:
add keys when you have a list of stateful widgets of the same type that can reorder, insert,
or delete. Static layouts almost never need them.
Don't cargo-cult keys
Keys are not a general "make it work" charm, and sprinkling them everywhere can cause bugs by defeating the reuse you actually want. If your widgets are stateless, or the list never changes order, you almost certainly don't need a key. Add one when you can name the exact reorder/insert scenario it fixes.
Glossary
- Key
- A stable identity for a widget, used during matching to pair it with the right element/state.
- ValueKey
- A key derived from a value you already have, like an item's id.
- GlobalKey
- A heavier key giving direct access to a widget's state or element from elsewhere. Use sparingly.
Check yourself
You reorder a list of stateful tiles and their internal state ends up on the wrong rows. The fix is to…
A stable key makes Flutter match state to identity rather than position, so each tile's state follows its data. setState (A) triggers the very rebuild that misbehaves; const (B) and inherited widgets (C) address unrelated concerns.
Part 2 · Module 12 of 12 · wrap-up
Tooling, testing & where to go next
Objective
Meet the daily tools that make Flutter pleasant — hot reload, DevTools, widget tests — and get an honest map of what's still ahead.
Flutter's headline feature for day-to-day work is hot reload. Save a file and your running app updates in well under a second, keeping its current state — you stay on the same screen, with the same data, and just see your change. After years of rebuild-and-relaunch cycles on native, this alone changes how you work: you tweak a padding, glance, tweak again, in a tight loop.
When something's off, Flutter DevTools gives you a live widget inspector (tap a pixel, see its widget tree), a layout explorer that visualises those constraints from module 3, plus performance, memory, and network views. The inspector especially is worth opening early — seeing your three trees rendered live cements everything in Part 1.
Testing comes in three tiers, and the middle one is Flutter's sweet spot:
| Tier | What it checks | Speed |
|---|---|---|
| Unit test | Plain Dart logic — no widgets. | Instant. |
| Widget test | A widget's behaviour: pump it, tap, assert what's on screen. | Fast, no device. |
| Integration test | The whole app on a real device or emulator. | Slow. |
testWidgets('tapping increments the counter', (tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('0'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add));
await tester.pump(); // let the rebuild happen
expect(find.text('1'), findsOneWidget);
});
Widget tests run in seconds with no emulator, and because your whole UI is a function of state, they're unusually reliable — you pump a widget, simulate taps, and assert on what's rendered. Coming from native UI testing's flakiness, this is a genuine upgrade.
The honest difficulty map
Now that the vocabulary means something, here's what's still ahead — ranked so you can tell a genuinely hard topic from a gap in your understanding.
| Topic | Why it takes time |
|---|---|
| Layout & constraints | The model is simple; applying it to real designs takes reps. tier 1 |
| State management | Not the libraries — deciding what state goes where. tier 1 |
| Async & rebuild timing | Where futures are created, when builders re-fire. tier 2 |
| Animations | Controllers, tweens, and the implicit/explicit split. tier 2 |
| Keys & the element tree | Rarely needed, confusing when it is. tier 2 |
| Slivers & custom render | The deep end of scrolling and painting. tier 3 |
| Platform channels | Calling native code. Only when you need a native API. tier 3 |
Where to go next
| Resource | Reach for it when |
|---|---|
| docs.flutter.dev | The canonical docs, cookbook, and samples. Start here. |
| dart.dev/language | You want the language properly, beyond §4. |
| The widget catalog | You need "what widget does X" — browsable with pictures. |
| Widget of the Week | Two-minute videos, one widget each. Great habit. |
| api.flutter.dev | Daily. The API reference is thorough and full of examples. |
| pub.dev | Finding and vetting packages — check popularity and maintenance. |
One closing thought, and it's the most useful sentence here. Nearly every Flutter surprise — the stale label, the overflow stripe, the form that resets, the wrong row's state — comes back to one of the ideas in Part 1: UI is a function of data, and there are three trees. When something baffles you, don't reach for a workaround. Ask which of those two ideas you've bumped into. That reframe is what turns the first fortnight from a fight into a conversation.
Check yourself
What makes hot reload different from a normal rebuild-and-relaunch?
Hot reload injects updated code into the live app and rebuilds from your existing state, so you stay on the same screen — the tight loop that makes Flutter pleasant. It's not about release-build speed (A), doesn't run tests (B), and applies to logic too, not just styling (D).