The Flutter mental model
You have built UIKit apps: a UIViewController owns a tree of UIView objects that live for as
long as the screen does, you mutate them in place (label.text = ...), and Auto Layout solves a
system of constraints to place them. Android Views are the same idea with XML and findViewById.
Flutter throws most of that away. This page is the replacement mental model, with the Footrace code as the examples. Read it before Day 2.
Flutter owns every pixel
Section titled “Flutter owns every pixel”Flutter does not use UILabel, UIButton, TextView or any other platform widget. The engine
(C++, shipped inside the app) gets one surface from the OS and paints everything onto it with its
own renderer. A Flutter “button” is Dart code that paints a rounded rectangle and listens for
pointer events. Platform look-alikes (Material, Cupertino) are re-implementations in Dart.
Consequences you will notice:
- The same Dart code draws the same pixels on Android and iOS. Footrace uses Material widgets with its own theme on both.
- Native UI concepts (view controllers, fragments, storyboards) exist only in the thin shell that hosts the engine. See the native shells below.
- Talking to the OS (GPS, keychain, notifications) goes through plugins, not through views. See platform channels and plugins.
Widgets are descriptions, not views
Section titled “Widgets are descriptions, not views”A widget is an immutable, cheap description of part of the UI. You never hold on to one and
mutate it. Instead, a widget’s build method returns new widgets every time it runs, and the
framework works out what changed.
Here is a complete widget from the design system:
// app/lib/design/components/fr_eyebrow.dart:8-28class FrEyebrow extends StatelessWidget { const FrEyebrow(this.text, {super.key, this.tone = FrEyebrowTone.muted});
final String text; final FrEyebrowTone tone;
@override Widget build(BuildContext context) { final fr = context.fr; final color = switch (tone) { FrEyebrowTone.accent => fr.accent, FrEyebrowTone.muted => fr.inkFaint, }; return Text( text.toUpperCase(), style: fr.eyebrow.copyWith(color: color), maxLines: 1, overflow: TextOverflow.ellipsis, ); }}All fields are final; the constructor is const. build is a pure function of the fields and
whatever it reads from context. If you know React, build is a render function and widgets are
elements returned from it. If you only know UIKit: build is nothing like viewDidLoad. It
does not run once. It runs whenever the framework decides this part of the screen might be stale,
which can be every frame during an animation. Never start work in build (no HTTP calls, no
timers, no subscriptions); put that in a provider or in initState.
There is no label.text = "...". To change what is on screen you change the inputs to build
(a widget’s state, or a provider it watches) and let build run again.
Three trees
Section titled “Three trees”Widgets are thrown away constantly, so something else must persist. Flutter keeps three parallel trees:
flowchart LR
subgraph W["Widget tree (immutable, rebuilt often)"]
w1[FrCard] --> w2[Padding] --> w3[Text]
end
subgraph E["Element tree (long-lived, holds State)"]
e1[StatefulElement + _FrCardState] --> e2[Element] --> e3[Element]
end
subgraph R["RenderObject tree (layout and paint)"]
r1[RenderDecoratedBox] --> r2[RenderPadding] --> r3[RenderParagraph]
end
w1 -.configures.-> e1
e1 -.owns.-> r1
| Tree | What it is | UIKit/Android equivalent |
|---|---|---|
| Widget | Immutable configuration, created by build |
The description you would put in a storyboard or XML |
| Element | The mounted instance at one position in the tree. Lives across rebuilds, holds the State object of a StatefulWidget, and is what BuildContext actually is |
The UIView / View instance itself |
| RenderObject | Does layout, painting and hit testing | CALayer plus the layout pass |
When a parent rebuilds, each child Element compares the new widget with its old one. Same
runtime type and same key: the Element stays, takes the new widget as its configuration, and
updates its RenderObject. Different type or key: the old Element (and its State) is thrown away
and a new one is created. That rule is why keys exist, and it is the single most useful thing to
understand when a screen “loses its state” unexpectedly.
You will almost never touch Elements or RenderObjects directly in this codebase. You write widgets; the framework maintains the other two.
Layout: constraints go down, sizes go up, parents set position
Section titled “Layout: constraints go down, sizes go up, parents set position”Auto Layout is a constraint solver: you state relationships between any views and it solves them together. Flutter’s layout is a single pass down and back up the tree:
- A parent passes its child constraints: a min and max width and height.
- The child picks a size within those constraints and returns it.
- The parent decides where to put the child.
A child never knows its own position, and it cannot be bigger than its parent allows. There are
no sibling-to-sibling constraints at all; a Row or Column is what arranges siblings.
The queue screen’s waiting state shows the usual tools:
// app/lib/features/queue/queue_screen.dart:172-190 (trimmed)return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Spacer(), FrCard( radius: FrRadii.xl, padding: const EdgeInsets.all(FrSpacing.xxl), child: Column( children: [ SizedBox( width: 72, height: 72, child: CircularProgressIndicator(/* ... */), ), const SizedBox(height: FrSpacing.xxl), // ...crossAxisAlignment: stretchhands every child a tight width (min = max = the column’s width), so the card fills the screen horizontally.Spacertakes a share of the leftover vertical space. There are two (lines 175 and 206), so the card sits in the middle. That replaces “center Y equal to superview” in Auto Layout.SizedBox(width: 72, height: 72)passes tight 72x72 constraints to the spinner, which has no choice but to be that size.SizedBox(height: ...)with no child is the standard spacer between items. There is nospacingproperty on aUIStackView-like container to set instead.
When you see the yellow-and-black overflow stripes in debug, a child asked for more than its
constraints allowed (usually text in a Row without Expanded). The fix is always to change the
constraints the parent gives, not to set a frame.
StatelessWidget, StatefulWidget and the State lifecycle
Section titled “StatelessWidget, StatefulWidget and the State lifecycle”A StatelessWidget has only build. A StatefulWidget is two classes: the immutable widget, and
a State<T> subclass that the Element keeps alive across rebuilds. The widget’s fields are read
from the State as widget.foo.
FrCard is stateful because it remembers whether a finger is down:
// app/lib/design/components/fr_card.dart:51-54class _FrCardState extends State<FrCard> { bool _pressed = false;
bool get _tappable => widget.onTap != null;and changes it with setState:
// app/lib/design/components/fr_card.dart:122-124onTapDown: (_) => setState(() => _pressed = true),onTapCancel: () => setState(() => _pressed = false),onTapUp: (_) => setState(() => _pressed = false),setState does not redraw anything immediately. It marks this Element dirty, and on the next
frame its build runs again with the new _pressed. The closure form is a convention; mutating
first and calling setState(() {}) afterwards does the same thing, and the race screen’s
one-second tick does exactly that to re-render the clock
(app/lib/features/race/race_screen.dart:44-46).
In the app, 31 files define at least one StatelessWidget and 5 a StatefulWidget. Screens that
read providers use Riverpod’s equivalents instead: ConsumerWidget (7 files) and
ConsumerStatefulWidget (10 files), which add a ref. See the Riverpod primer.
Lifecycle, mapped to what you know
Section titled “Lifecycle, mapped to what you know”Flutter State |
Runs | Closest UIKit | Closest Android |
|---|---|---|---|
initState |
Once, when the Element is first mounted | viewDidLoad |
onCreate / onViewCreated |
didChangeDependencies |
After initState, and whenever an inherited value it read (theme, MediaQuery) changes |
trait collection change | configuration change |
build |
Whenever dirty; often | (no equivalent: closest is layoutSubviews, and even that is a stretch) |
(none) |
didUpdateWidget |
Parent rebuilt with a new widget of the same type | (none) | (none) |
dispose |
Element removed for good | deinit |
onDestroy |
There is no viewWillAppear/viewDidAppear. “This screen became visible” is a routing
question in Flutter, and app foreground/background is a separate observer.
The race screen is the clearest example of acquire-in-initState, release-in-dispose:
// app/lib/features/race/race_screen.dart:41-55@overridevoid initState() { super.initState(); _tick = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) setState(() {}); }); WakelockPlus.enable();}
@overridevoid dispose() { _tick?.cancel(); WakelockPlus.disable(); super.dispose();}The queue screen adds the app-lifecycle observer, which is how a Flutter widget hears about
applicationDidEnterBackground / onPause:
// app/lib/features/queue/queue_screen.dart:60-71@overridevoid didChangeAppLifecycleState(AppLifecycleState state) { final notifier = ref.read(queueProvider(widget.raceTypeSlug).notifier); if (state == AppLifecycleState.paused) { _backgroundTimer?.cancel(); _backgroundTimer = Timer(QueueScreen.backgroundGrace, notifier.pausePolling); } else if (state == AppLifecycleState.resumed) { _backgroundTimer?.cancel(); notifier.resumePolling(); }}It registers in initState and unregisters in dispose
(app/lib/features/queue/queue_screen.dart:44 and :54). FrLiveDot uses didChangeDependencies
because it reads MediaQuery (reduced-motion setting), which is not available in initState
(app/lib/design/components/fr_section_header.dart:112-122).
mounted
Section titled “mounted”A State can outlive its usefulness while an await is pending: the user navigated away, the
Element was disposed, and now the Future completes. Touching context or calling setState
then is an error. Hence the guard that appears after almost every await in a widget:
// app/lib/features/lobby/lobby_screen.dart:71-72final race = await ref.read(lobbyRepositoryProvider).join(entry.id);if (mounted) context.go(AppRoutes.race(race.id));This is the Flutter version of checking weak self after an async callback.
BuildContext
Section titled “BuildContext”BuildContext is the Element, handed to build as an interface. It is your position in the
tree, and its main job is looking up the tree for things provided by ancestors:
Theme.of(context)finds the nearestThemeabove you (app/lib/features/race_tab/race_tab.dart:29).MediaQuery...Of(context)reads screen size, insets and accessibility flags.ScaffoldMessenger.of(context)finds the nearest scaffold to show a snackbar (app/lib/features/lobby/lobby_screen.dart:79-80).context.go(...)andcontext.push(...)come from go_router and find the router above you (app/lib/features/race_tab/race_tab.dart:51).context.fris this app’s own extension that finds the Footrace theme extension (app/lib/design/typography.dart:309-313).
The Foo.of(context) lookups are inherited widgets: an ancestor widget that descendants can
find in O(1) and that rebuilds them when it changes. Think of it as SwiftUI’s environment, or a
React context. Riverpod’s ProviderScope is built on the same mechanism.
Two rules: use the context you were given, not one stashed from elsewhere, and do not use it
after an await without checking mounted.
Keys change the Element-reuse rule from “same type at the same position” to “same type and same key”. You need them in three situations, and this codebase has all three:
- Finding a widget in tests.
RaceScreen.leaveKey(app/lib/features/race/race_screen.dart:30) exists so the integration test can drag the slider:find.byKey(RaceScreen.leaveKey)(app/integration_test/full_race_test.dart:119). The screenshot tests tap race-type cards byValueKey('race-type-card-five_km'). - List identity. Rows built from data carry a key derived from the record’s id, for example
ValueKey('history-${race.id}')(app/lib/features/history/history_tab.dart:188). If rows are inserted or reordered, each Element follows its row instead of its index, the same reason React wantskeyon list items. (Here the keys also serve tests.) - Reaching into a State from outside. A
GlobalKey<FormState>lets the login screen callvalidate()on the form it built:if (!_formKey.currentState!.validate()) return;(app/lib/features/auth/login_screen.dart:21and:35). It is the nearest thing Flutter has to anIBOutlet. Use sparingly; it is the only one of the three that costs anything.
Navigation in one paragraph
Section titled “Navigation in one paragraph”There is no UINavigationController you push view controllers onto. The root widget is a
MaterialApp.router configured by go_router (app/lib/app/app.dart:16-20), routes are declared
as data in app/lib/app/router.dart, and screens navigate by URL: context.go('/race/123'). A
redirect function decides where an unauthenticated user goes, which you can read as a Phoenix
plug pipeline. The full story is on the app shell page.
Hot reload, hot restart, full rebuild
Section titled “Hot reload, hot restart, full rebuild”flutter run in debug compiles Dart to a JIT VM on the device and keeps a connection open:
| Action | Key | What happens | Keeps state? |
|---|---|---|---|
| Hot reload | r |
Changed libraries are injected into the running VM; the framework rebuilds the tree from the root | Yes: every State, every provider |
| Hot restart | R |
The Dart VM state is thrown away and main() runs again |
No |
| Full rebuild | stop and flutter run again |
Native code recompiled, app reinstalled | No |
Rules of thumb for this repo:
- Edits inside a
buildmethod, a widget’s styling, orlib/design/tokens.dart: hot reload. - Edits to
initState, a notifier’sbuild, ormain.dart: hot restart, because that code already ran and reload will not run it again. - Changes to a
--dart-define(they are compile-time constants, seeapp/lib/core/config/app_config.dart:41-48),pubspec.yamldependencies,AndroidManifest.xml,Info.plistor anything underandroid/andios/: full rebuild. - Code that runs in the GPS service isolate (
app/lib/tracking/location_service.dart): I could not verify whether hot reload reaches a running service isolate. Stop and restart tracking, or hot restart, after editing it.
Release builds are ahead-of-time compiled to native code with no VM connection, which is why
kDebugMode and kReleaseMode exist as constants (app/lib/main.dart:17,
app/lib/core/config/app_config.dart:29).
The native shells: android/ and ios/
Section titled “The native shells: android/ and ios/”The android/ and ios/ folders are ordinary native projects whose only job is to start the
Flutter engine and give it a surface. They are nearly empty on purpose.
On Android the whole activity is one line:
// app/android/app/src/main/kotlin/com/footraceapp/footrace_flutter/MainActivity.kt:3-5import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()FlutterActivity creates a FlutterEngine, which starts the Dart VM and runs main(), and
attaches a FlutterView that fills the window. The manifest declares the permissions the plugins
need and the foreground service the GPS tracker runs in
(app/android/app/src/main/AndroidManifest.xml:2-9 and :36-40).
On iOS, AppDelegate subclasses FlutterAppDelegate and registers the plugins with the engine
(app/ios/Runner/AppDelegate.swift:5 and :13-15). The storyboard’s root view controller is a
FlutterViewController (app/ios/Runner/Base.lproj/Main.storyboard:11), the iOS counterpart of
FlutterActivity. Info.plist carries the location usage strings and the location background
mode (app/ios/Runner/Info.plist:69-77).
flowchart TB
subgraph Native["Native shell (android/, ios/)"]
A["MainActivity : FlutterActivity<br/>AppDelegate : FlutterAppDelegate"]
end
subgraph Engine["Flutter engine (C++)"]
VM[Dart VM / AOT code] --- RND[Renderer]
end
subgraph Dart["Your Dart (lib/)"]
M["main() → runApp(ProviderScope(FootraceApp))"]
end
A -->|creates, gives a surface| Engine
Engine -->|runs| Dart
Dart -->|plugin calls over platform channels| P["Plugin native code<br/>(geolocator, flutter_foreground_task, ...)"]
P --> OS[(Android / iOS APIs)]
The Dart side starts at main():
// app/lib/main.dart:9-21void main() { WidgetsFlutterBinding.ensureInitialized(); installErrorHandlers(); // Opens the channel the foreground-service isolate uses to send GPS // batches to the UI. Must happen before any tracking starts. FlutterForegroundTask.initCommunicationPort(); runApp( const ProviderScope( observers: [if (kDebugMode) LoggingProviderObserver()], child: FootraceApp(), ), );}ensureInitialized connects Dart to the engine before anything uses a plugin; runApp mounts the
root widget. Everything after that is widgets.
Platform channels and plugins
Section titled “Platform channels and plugins”When Dart needs the OS, it sends a message over a platform channel: a named, asynchronous
message pipe between Dart and Kotlin/Swift, with arguments serialised to a small set of types
(maps, lists, strings, numbers, byte buffers). The native side handles it and replies. It is a
local RPC, and every call is a Future.
This app writes no channels of its own: there is no MethodChannel anywhere in lib/. It uses
plugins, which are packages that bundle a Dart API with the native code behind it:
| Plugin | What the app uses it for | Where |
|---|---|---|
geolocator |
GPS fixes and location permission | app/lib/tracking/location_source.dart |
flutter_foreground_task |
Android foreground service (and its iOS counterpart) that keeps tracking with the screen locked | app/lib/tracking/location_service.dart |
flutter_secure_storage |
The JWT in the Keychain / Keystore | app/lib/data/storage/token_store.dart |
shared_preferences |
Cached user, active race id | app/lib/data/storage/user_cache.dart, active_race_store.dart |
path_provider |
Where to put the sembast fix queue | app/lib/tracking/location_service.dart:334 |
wakelock_plus |
Keep the screen on during a race | app/lib/features/race/race_screen.dart:47 |
Registration is generated: GeneratedPluginRegistrant is written by the Flutter tool at build
time (on Android it is git-ignored, see app/.gitignore), and the iOS AppDelegate calls it.
The foreground service is the one place the native layer matters for behaviour. On Android,
flutter_foreground_task starts a real Android Service that runs a second Dart isolate, which
is what keeps GPS sampling with the screen locked. The Dart primer’s isolate section
covers the language side and tracking the app side. How the plugin keeps
Dart running on iOS I did not verify from the plugin’s native code.
Summary for the impatient
Section titled “Summary for the impatient”- Widgets are immutable descriptions.
buildis a render function that runs often. - Elements persist and hold
State. Same type and key at the same position means “keep it”. - Layout is one pass: constraints down, sizes up, parent positions.
initState/disposebracket a State’s life. Checkmountedafterawait.BuildContextlooks up the tree.Theme.of,context.goandcontext.frall work that way.- Hot reload keeps state; anything that already ran needs a hot restart.
android/andios/only host the engine; plugins reach the OS for you.