Skip to content

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 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.

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-28
class 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.

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:

  1. A parent passes its child constraints: a min and max width and height.
  2. The child picks a size within those constraints and returns it.
  3. 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: stretch hands every child a tight width (min = max = the column’s width), so the card fills the screen horizontally.
  • Spacer takes 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 no spacing property on a UIStackView-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-54
class _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-124
onTapDown: (_) => 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.

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
@override
void initState() {
super.initState();
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
WakelockPlus.enable();
}
@override
void 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
@override
void 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).

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-72
final 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 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 nearest Theme above 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(...) and context.push(...) come from go_router and find the router above you (app/lib/features/race_tab/race_tab.dart:51).
  • context.fr is 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 by ValueKey('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 wants key on list items. (Here the keys also serve tests.)
  • Reaching into a State from outside. A GlobalKey<FormState> lets the login screen call validate() on the form it built: if (!_formKey.currentState!.validate()) return; (app/lib/features/auth/login_screen.dart:21 and :35). It is the nearest thing Flutter has to an IBOutlet. Use sparingly; it is the only one of the three that costs anything.

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.

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 build method, a widget’s styling, or lib/design/tokens.dart: hot reload.
  • Edits to initState, a notifier’s build, or main.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, see app/lib/core/config/app_config.dart:41-48), pubspec.yaml dependencies, AndroidManifest.xml, Info.plist or anything under android/ and ios/: 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 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-5
import 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-21
void 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.

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.

  • Widgets are immutable descriptions. build is 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/dispose bracket a State’s life. Check mounted after await.
  • BuildContext looks up the tree. Theme.of, context.go and context.fr all work that way.
  • Hot reload keeps state; anything that already ran needs a hot restart.
  • android/ and ios/ only host the engine; plugins reach the OS for you.