Skip to content

Day 2: Widgets and the tree

Today is about the one idea everything else in Flutter rests on: the UI is a tree of immutable widget objects, rebuilt from scratch by build() methods whenever something they depend on changes. You read the root widget, the theme it installs, and the leaderboard, the simplest real screen in the app, from route to pixel. Then you look at one stateful widget, the permissions sheet, to see where mutable state lives when a widget needs it.

Day 1 covered the Dart you need here: const constructors, named parameters, final fields, library privacy with _, and package: imports. You also saw runApp(const ProviderScope(child: FootraceApp())) in app/lib/main.dart:15-20. Today starts at FootraceApp.

The Flutter mental model primer explains widgets, elements and render objects at more length than fits here. Read it after today if the three-tree idea below does not click.

Open app/lib/app/app.dart. The whole file:

class FootraceApp extends ConsumerWidget {
const FootraceApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Keeps the socket connected for as long as the user is signed in.
ref.watch(socketLifecycleProvider);
return MaterialApp.router(
title: 'Footrace',
theme: footraceTheme(),
routerConfig: ref.watch(routerProvider),
);
}
}

(app/lib/app/app.dart:9-22). Four things to see.

A widget is a description, not a view. FootraceApp has no fields to mutate, no view property, no lifecycle callbacks. It has a constructor and a build method that returns other widgets. Flutter calls build, compares the result to what it built last time, and updates the real UI underneath. If you know React, build() is a render function and this is a function component. It is nothing like viewDidLoad or onCreate: it runs whenever an input changes, possibly many times a second, so it must be cheap and must not start work. Side effects go elsewhere (Day 5 shows where).

Three trees, briefly. Widgets are throwaway configuration objects. Flutter keeps a parallel tree of elements, long-lived, one per widget position, which hold state and know their parent. Elements own render objects, which do layout and painting. When you rebuild, new widgets are created and the existing elements are updated with them. UIKit has one tree (UIViews) that you mutate; Flutter has a tree you rebuild and two it reconciles for you.

The three widget base classes you will see everywhere:

Base class Has You know it as
StatelessWidget build(context) a React function component without hooks
StatefulWidget + State<T> a separate, long-lived State object with build, initState, dispose, setState a React class component; a view controller that owns a few fields
ConsumerWidget (Riverpod) build(context, ref) a function component that reads from a store via ref.watch

FootraceApp is a ConsumerWidget, so its build also gets a WidgetRef. Each ref.watch(...) subscribes the widget to a provider and rebuilds it when the provider changes. ref.watch(socketLifecycleProvider) (line 15) is there only for its side effect: watching the provider keeps it alive, and the provider keeps the Phoenix socket connected. Day 7 opens it.

super.key (line 10) forwards an optional key parameter to the base class. Keys tell Flutter which old element a new widget corresponds to when siblings move around; you will see explicit ones in the leaderboard.

MaterialApp.router (lines 16-20) is the app’s frame: it installs the theme, localisation, the navigator and the router. routerConfig comes from routerProvider, which is Day 4.

Open app/lib/app/theme.dart. footraceTheme() (line 12) is a plain top-level function that returns a ThemeData. Material widgets read their colours, fonts and shapes from that object, so setting it once here styles every FilledButton, AppBar and TextField in the app.

It starts with a ColorScheme (lines 13-37): Material’s fixed set of colour roles (primary, onPrimary, surface, error, …) mapped to Footrace’s tokens. primary is FrColors.accent, the orange; onSurface is FrColors.ink. A Material widget never says “orange”; it says “primary”, and this table decides.

Then the ThemeData itself (lines 42-247): global settings first (fontFamily, textTheme, scaffoldBackgroundColor), then one sub-theme per component. Look at the filled button:

filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: FrColors.accent,
foregroundColor: FrColors.onAccent,
// ...
minimumSize: const Size.fromHeight(52),
// ...
).copyWith(
overlayColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.pressed)
? FrColors.accentPressed
: null,
),
),
),

(app/lib/app/theme.dart:77-94, trimmed). WidgetStateProperty.resolveWith is a function from the control’s current states (pressed, hovered, disabled, selected) to a value. That is an honest match for UIKit’s setTitleColor(_:for: .highlighted) or an Android ColorStateList selector, written as a closure. Size.fromHeight(52) means “52 tall, as wide as you are allowed”, which is why every filled button in the app spans the screen.

Two lines at the top deserve a note. export '../design/tokens.dart'; and export '../design/typography.dart'; (lines 6-7) re-export those files, so importing theme.dart also gives you FrColors, FrSpacing and friends, like an index.ts that re-exports. And extensions: const [FrTheme.light] (line 51) attaches Footrace’s own theme object to the Material one. Day 3 is about that.

How a widget finds the theme. MaterialApp puts a Theme widget above every route. Any widget below it calls Theme.of(context), which walks up the element tree from context to the nearest Theme and returns its data. It also registers a dependency, so if the theme changes the widget rebuilds. This is React context (useContext), and it is the reason build takes a BuildContext: the context is the widget’s element, its position in the tree. You cannot ask for the theme without saying where you are standing.

The leaderboard is a list of runners with a row of filter chips above it. It is reached from the leaderboard icon button (tooltip “Leaderboard”) in the Race tab’s app bar: onPressed: () => context.push(AppRoutes.leaderboard) (app/lib/features/race_tab/race_tab.dart:51), and the router maps that path to const LeaderboardScreen() (app/lib/app/router.dart:86-89).

Open app/lib/features/leaderboard/leaderboard_screen.dart.

class LeaderboardScreen extends ConsumerWidget {
const LeaderboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final slug = ref.watch(leaderboardFilterProvider);
final async = ref.watch(leaderboardProvider(slug ?? ''));
final types =
ref.watch(raceTypesProvider).valueOrNull ?? const <RaceType>[];
final myId = ref.watch(authProvider).valueOrNull?.user?.id;
return Scaffold(
appBar: AppBar(title: const Text('Leaderboard')),
body: Column(
children: [
_Filter(types: types),
Expanded(
child: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => MessageState(/* ... */),
data: (page) => _List(page: page, myId: myId, slug: slug),
),
),
],
),
);
}
}

(app/lib/features/leaderboard/leaderboard_screen.dart:22-56, error branch trimmed.)

The first four lines of build (27-31) gather inputs: the selected distance, the page of standings for it, the list of race types for the chips, and your user id. Each ref.watch is a subscription. Pick another chip and leaderboardFilterProvider changes, build runs again, slug is new, and leaderboardProvider(slug) fetches that distance. You never call “reload”. Where the data comes from is Day 5 and Day 6; today treat the providers as inputs.

async.when(loading:, error:, data:) (lines 39-50) is how the app renders anything that loads. AsyncValue is a sealed type with three cases, and when makes you handle all three: the Kotlin when over a sealed class, with a closure per case.

Scaffold (line 33) is the screen frame: app bar, body, optional bottom bar and floating button. It is the closest thing to a UIViewController’s root view with a navigation bar attached, except it is just another widget.

Flutter layout is one rule, applied recursively: constraints go down, sizes go up, the parent sets the position. A parent tells each child “you may be between this min and max width and height”; the child picks a size inside that; the parent places it. There is no Auto Layout solver and no LayoutParams negotiation. It is closer to CSS flexbox done in a single pass.

Column (line 35) lays children out vertically. It gives non-flexible children unbounded height (“as tall as you like”) and then sizes itself. That is a problem for a list, which would like to be infinitely tall. So the list goes inside Expanded (line 38), which says “give this child whatever height is left after the others”. Remove the Expanded and Flutter throws the most famous error in the framework, “Vertical viewport was given unbounded height”. Expanded is flex: 1 in flexbox, or a weight="1" child in an Android LinearLayout.

The filter row solves the same problem the other way. A horizontal ListView inside a Column has a bounded width but unbounded height, so _Filter gives it a fixed box:

return SizedBox(
height: 60,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: FrSpacing.gutter,
vertical: FrSpacing.md,
),
children: [
_Chip(label: 'All distances', slug: null, selected: selected == null),
for (final type in types)
_Chip(
label: formatter.distance(type.meters),
// ...
),
],
),
);

(app/lib/features/leaderboard/leaderboard_screen.dart:70-92, trimmed.) Here padding is a parameter of ListView; elsewhere it is a Padding widget wrapping its child, as in _Chip (line 108). Note the collection for from Day 1 building one chip per race type, and formatter.distance from units.dart producing the chip labels.

The file defines five private widgets: _Filter, _Chip, _List, _Row, _Rank. Splitting a screen into small widget classes is the normal style, not over-engineering: each one is a rebuild boundary, and the underscore keeps them out of every other file.

_List (lines 120-189) has the interesting logic:

final mine = page.rowFor(myId);
final myIndex = mine == null ? -1 : page.entries.indexOf(mine);
// Pin my row only when it is far enough down to be off screen anyway.
final pinned = mine != null && myIndex >= 8;
return Column(
children: [
Expanded(
child: RefreshIndicator(
onRefresh: refresh,
child: ListView.separated(
// ...
itemCount: page.entries.length,
separatorBuilder: (_, __) =>
const SizedBox(height: FrSpacing.listGap),
itemBuilder: (context, index) => _Row(
entry: page.entries[index],
isMe: page.entries[index].userId == myId,
),
),
),
),
if (pinned)
Container(
// ...
child: _Row(entry: mine, isMe: true),
),
],
);

(app/lib/features/leaderboard/leaderboard_screen.dart:143-187, trimmed.)

  • ListView.separated with an itemBuilder builds rows lazily as they scroll into view: the tableView(_:cellForRowAt:) or RecyclerView.Adapter.onBindViewHolder of Flutter, minus the cell reuse bookkeeping.
  • RefreshIndicator wraps the list to add pull-to-refresh; onRefresh takes a function returning a Future, and the spinner stays until it completes. refresh is a local function defined at lines 130-131.
  • (_, __) names two parameters the closure ignores.
  • if (pinned) is a collection if inside children: your own row appears a second time, pinned at the bottom, only when you are ninth or lower.
  • Container is a convenience widget that combines padding, decoration and size. The decoration reads colours from context.fr, which is Day 3.

_Row (lines 191-227) returns an FrListRow from the design system and wires the tap: onTap: isMe ? null : () => context.push(AppRoutes.user(entry.userId)) (line 217). A null callback is how Flutter says “disabled”. The ValueKey('leaderboard-row-...') at line 205 gives the row a stable identity, and the widget tests find rows by it.

_Rank (lines 230-258) is a plain StatelessWidget: it needs no providers, so it does not take a ref. A 44 by 44 circle with the rank number, accent-filled when it is yours.

Most screens in the app are ConsumerWidgets because their state lives in providers. When a widget needs state of its own, or lifecycle hooks, it becomes stateful. Open app/lib/features/permissions/permissions_screen.dart.

class PermissionsSheet extends ConsumerStatefulWidget {
// ...
@override
ConsumerState<PermissionsSheet> createState() => _PermissionsSheetState();
}
class _PermissionsSheetState extends ConsumerState<PermissionsSheet>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(permissionGateProvider.notifier).refresh();
}
}

(app/lib/features/permissions/permissions_screen.dart:9-56, trimmed.) The widget class is still immutable; it only knows how to create its State. The State object is long-lived, survives rebuilds, and has the lifecycle: initState once when it enters the tree, dispose once when it leaves. That pair is your viewDidLoad/deinit or onCreate/onDestroy; build is still the render function. with WidgetsBindingObserver mixes in an interface with default methods (a Dart mixin, close to a Swift protocol extension or a Kotlin interface with bodies), and didChangeAppLifecycleState is applicationDidBecomeActive or onResume: coming back from the system settings re-checks the permissions.

Things to notice in build (lines 58-121):

  • Theme.of(context).textTheme (line 61) and Theme.of(context).colorScheme.secondary (line 88): the InheritedWidget lookup from earlier.
  • Navigation cannot happen in the middle of a build. When permissions are granted, the sheet schedules its own close for after the frame: addPostFrameCallback at lines 64-66, with an if (mounted) check because the widget may be gone by then.
  • Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch) (lines 83-86): as short as its children, and each child as wide as the column.
  • The copy comes from a switch expression over an enum (lines 123-172). Every case returns a value and the compiler checks the enum is covered, like a Kotlin when used as an expression.

The static show (lines 13-20) opens the sheet with showModalBottomSheet<bool>, which returns a Future<bool?> that completes when the sheet is popped with a value, and ?? false turns “dismissed” into a no. ensureGranted (lines 24-29) checks context.mounted after an await: after any async gap, the widget that owned context may have left the tree. You will see that check after almost every await in a widget.

One thing this file does not do: it uses literal numbers (EdgeInsets.fromLTRB(24, 0, 24, 24) at line 71, SizedBox(height: 16) at line 89) where the leaderboard uses FrSpacing tokens. CLAUDE.md says widgets should not use literal sizes. Treat the leaderboard as the house style and this file as older code that predates it.

At the bottom, PermissionBanner (lines 191-224) shows a Row with Expanded around the Text (lines 206-220). Same rule as the column, sideways: without Expanded, a long message would overflow instead of wrapping, and you would see the yellow-and-black overflow stripes.

1. A device-free change with a test. The pinned-row rule is myIndex >= 8 at app/lib/features/leaderboard/leaderboard_screen.dart:146. Read the two tests that cover it, app/test/features/leaderboard/leaderboard_screen_test.dart:107-138: one puts you eleventh (index 10), one second (index 1). Change the threshold to >= 1 and predict which test fails. Then run:

Terminal window
flutter test test/features/leaderboard/leaderboard_screen_test.dart

“a row near the top is not pinned twice” should fail: it expects Runner 2 (you) once and now finds it twice, once in the list and once pinned. Revert.

2. Hot reload (needs an emulator or simulator). Start an Android emulator or iOS simulator, then the backend (cd ../footrace && PORT=4001 mix phx.server), then the app:

Terminal window
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:4001 --dart-define=DEV_TOOLS=true

On an iOS simulator use http://localhost:4001. Sign in, open the leaderboard from the icon in the Race tab’s app bar. Now edit _Rank (leaderboard_screen.dart:240-241) to width: 56, height: 56, save, and press r in the terminal. The circles grow in under a second and you stay on the same screen with the same filter selected: hot reload re-runs build with the new code while keeping every State and provider. Then change the AppBar title at line 34 and reload again. Finally press R (hot restart): the app restarts from main() and the auth gate sends you through the splash. Changes to initState, main() or a --dart-define need a restart, because hot reload does not re-run code that already ran.

Without the backend the leaderboard shows the error card with a Retry button instead; the hot-reload part still works on that.

  1. Why must build() be cheap and free of side effects, and how is that different from viewDidLoad?
  2. What does Theme.of(context) actually do, and why does it need context?
  3. Remove the Expanded around async.when(...) in LeaderboardScreen. What happens, and why?
  4. In PermissionsSheet, which object survives a rebuild: PermissionsSheet or _PermissionsSheetState? Where does initState live?
  5. Why does the sheet close itself inside addPostFrameCallback instead of calling Navigator.of(context).pop(true) directly in build?
Answers
  1. Flutter calls build every time a dependency changes, possibly many times a second, and may call it for reasons you do not control. viewDidLoad runs once per view controller. Work that should happen once goes in initState, a provider, or a callback.
  2. It walks up the element tree from the widget’s position (the BuildContext is the element) to the nearest Theme, returns its ThemeData, and registers a dependency so the widget rebuilds if the theme changes. Without context there is no position to start from.
  3. A runtime layout error (“Vertical viewport was given unbounded height”). Column gives non-flexible children unbounded height, and a scrolling list cannot size itself to infinity. Expanded gives it the remaining height as a tight constraint.
  4. _PermissionsSheetState. The widget is a new immutable object on every rebuild; the State is created once by createState and kept by the element. initState and dispose are on the State.
  5. Navigating changes the tree, and you cannot change the tree while it is being built. The post-frame callback runs after the current frame is done, and mounted guards against the sheet having already gone.

Day 3: The design system and Widgetbook opens context.fr, the tokens and the components the leaderboard is built from.