Skip to content

How to read Flutter code you did not write

Every earlier module in this course taught you to write widgets, state, navigation, and platform code. This module teaches the other half: reading a StatefulWidget, a ConsumerWidget, a go_router config, or an entire feature someone — or something — else wrote, and deciding whether it is actually correct. An AI assistant hands you a screen that renders perfectly the first time you run it, and that success is the trap: the emulator shows the right pixels, flutter analyze is silent, and the demo works — while the code silently assumes a widget will never be disposed mid-flight, a list will never reorder, an isolate boundary will never see a BuildContext, or a route will never be visited before its sibling. Flutter’s tools validate almost nothing about behavior. flutter analyze proves types line up; it has no opinion on whether setState after an await is safe, whether a key is missing, or whether a ListView has anywhere to put its height.

Three things make this worse for Flutter specifically. First, Flutter and Dart genuinely changed shape mid-training-corpus: WillPopScope was deprecated for PopScope, StateNotifierProvider/StateProvider moved to Riverpod’s legacy.dart import, class modifiers and sealed classes changed how state unions get written, and material_ui/cupertino_ui started splitting off Material/Cupertino from package:flutter/material.dart — so a large fraction of “current” Flutter snippets online describe an API that compiles under an old SDK and throws an analyzer diagnostic under this course’s pinned Flutter 3.47.5 / Dart 3.13. Second, a huge share of public Flutter examples are demo-grade — a single screen tuned to “it renders once,” never a real app tuned for “it survives a rotation, a rebuild, and a disposed widget mid-await” — so patterns that are harmless in a 30-line gist (no mounted guard, no key, a Future built fresh in build()) get copied verbatim into code that cannot afford them. Third, the fastest fix an AI assistant reaches for after a layout crash is often the one closest at hand rather than the one that is correct — wrapping something in Expanded because the error message says the word, without checking there is a Flex for it to be a child of.

flowchart TD
  A["1. pubspec &amp; SDK constraints<br/>which Flutter/Dart, which package versions does this assume?"] --> B["2. State approach<br/>Provider, Riverpod, Bloc, setState -- and is it wired up at all (ProviderScope)?"]
  B --> C["3. Widget tree shape<br/>keys on reordered children, const opportunities, GlobalKey use"]
  C --> D["4. Lifecycle &amp; async gaps<br/>setState after dispose, setState during build, missing mounted"]
  D --> E["5. Layout constraints<br/>unbounded height, Expanded outside a Flex, overflow"]
  E --> F["6. Navigation<br/>go_router route order, go vs push, redirect + refreshListenable"]
  F --> G["7. Platform &amp; isolates<br/>dart:io on web, MissingPluginException, compute() + BuildContext"]
  G --> H["8. Tests, lints &amp; release config<br/>which lints actually run, print in release, --wasm, coverage"]
Reading order for Flutter code you did not write

Before anything about the code itself, check what SDK and package versions the code assumes — because Flutter’s own APIs (PopScope, material_ui, Riverpod’s legacy.dart split) and go_router’s route-matching behavior have all changed across versions this course targets directly, and a snippet written against an older SDK can still parse and even analyze cleanly, only to hit a real deprecation or missing-name diagnostic under 3.47.5.

  • What to look for: does pubspec.yaml’s environment: sdk: constraint actually admit Dart 3.13 (or does it read like it was copied from a Dart 2 or early-Dart-3 project)? Does the code import package:flutter_riverpod/flutter_riverpod.dart and then call StateProvider/StateNotifierProvider directly — APIs that moved to a separate legacy.dart import in Riverpod 3? Does a pubspec.yaml pin custom_lint and riverpod_lint together without checking they actually resolve (they frequently do not — proven in Verification Tools)?
  • The question to ask: would this exact import, this exact named parameter, this exact class resolve on the SDK and package versions actually declared — or does it just look plausible because the shape of the API hasn’t changed even though the entry point has?
  • Which lesson explains it: the AI Bug Catalog’s State & lifecycle group for the real undefined_function diagnostic StateProvider produces without legacy.dart; Project and Tooling for pubspec.yaml structure in full.

Once the SDK is confirmed, check which state-management approach the code claims to use and whether it is actually wired up — Riverpod’s most common failure mode is not a wrong answer, it is a crash the instant a ConsumerWidget tries to read anything, because the one widget that makes state readable at all was never added.

  • What to look for: is there a ProviderScope anywhere above the first ConsumerWidget/ref.watch call? Does a .family provider have .autoDispose paired with it (an unpaired .family leaks one controller instance per parameter value, forever)? Is ref.read called inside build() (silently non-reactive) or ref.watch called inside a callback (throws)? Does the code reach for StateNotifierProvider/StateProvider from a Riverpod 2 tutorial when this course’s Riverpod 3 has moved both behind an explicit legacy.dart import?
  • The question to ask: if this exact widget is the very first ConsumerWidget built after runApp(), does anything above it in the tree actually create the ProviderContainer its ref needs — or does this only work by accident, because some ancestor screen happened to add the scope already?
  • Which lesson explains it: the AI Bug Catalog’s State & lifecycle group (missing ProviderScope, legacy StateProvider) and Verification Tools for a real riverpod_lint missing_provider_scope finding; Provider and Riverpod for the mechanism in full.

Once state is confirmed wired up, check the shape of the widget tree itself — Flutter matches widgets to their State by type and position unless told otherwise, and an AI assistant that has never had to debug a reordered list has no instinct for when that default silently attaches the wrong state to the wrong row.

  • What to look for: does a list of stateful widgets built with for/.map() have a key at all, when its order can change (drag-to-reorder, sort, filter)? Is the same GlobalKey constant reused across sibling widgets instead of UniqueKey()/a ValueKey per item? Are compile-time-constant widgets actually marked const — and does the project’s lint set even catch the ones that aren’t (flutter_lints 6 does not ship the general prefer_const_constructors rule, only the narrower _in_immutables variant)?
  • The question to ask: if this exact list’s order changes on the next frame — a reorder, a delete, a filter — does each row’s own local state (a checkbox, an expanded/collapsed flag, a text field’s cursor) travel WITH its data, or does it stay pinned to a screen position and end up on the wrong row?
  • Which lesson explains it: the AI Bug Catalog’s Widget tree & keys group, proven with a real reorder test and a real Duplicate keys found FlutterError; Keys for the mechanism in full.

The single most common class of Flutter bug in AI-generated code is not a wrong answer at all — it is a correct answer computed after the widget asking for it is already gone, because nothing checked mounted between the await and the setState.

  • What to look for: does every setState reachable from a .then(), an await continuation, a Timer, or a Stream listener check mounted first? Does a widget’s build() call setState on a different widget synchronously, from inside that widget’s own build() (the “cross-sibling” trap)? Is a late field ever read on a code path where the method that assigns it might not have run yet?
  • The question to ask: if the user navigates away, scrolls this widget out of a lazily-built list, or otherwise disposes this exact State object before this specific pending Future/Stream event resolves, does anything stop the resulting callback from touching a State that no longer exists?
  • Which lesson explains it: the AI Bug Catalog’s State & lifecycle group, proven with real setState() called after dispose() and setState() or markNeedsBuild() called during build errors captured fresh for this module; setState and Lifecycle for the full lifecycle order.

Flutter’s layout crashes are almost all the same shape once you know the rule — an axis that wants to be as big as its content is placed somewhere that offers unlimited space in that same direction — and AI-generated code reaches for the first fix that mentions the right widget name (Expanded) without checking it has a Flex to be a child of.

  • What to look for: is a ListView/GridView/other scrollable placed directly inside a Column/Row with nothing bounding its main-axis size? (The reverse — a Column inside a ListView — needs nothing extra; a scrollable already gives its children a bounded cross-axis and unbounded main-axis, which is exactly what a naturally-sized Column wants.) Is Expanded/Flexible used as a child of something that is not a Row/Column/Flex? Does a Row of fixed-width children have no Expanded/Flexible/scrolling escape hatch for content wider than the viewport?
  • The question to ask: walking from this exact widget up to the nearest ancestor that provides a bounded constraint in the axis that’s overflowing or crashing, is there one at all — and if this widget’s natural size ever exceeds what’s actually available, is there a flex factor, a scrollable, or a clip standing between them?
  • Which lesson explains it: the AI Bug Catalog’s Layout group, proven with real Vertical viewport was given unbounded height, Incorrect use of ParentDataWidget, and RenderFlex overflowed errors; Constraints and Layout for the constraints-down-sizes-up model in full.

go_router’s route matching is declaration-order-sensitive in a way nothing in the type system catches, and its redirect callback only re-runs on navigation events by default — both are common places where AI-generated routing config compiles, runs, and quietly does the wrong thing.

  • What to look for: is a parameterized route (/pets/:id) declared before a more specific static route at the same segment level (/pets/new)? Is context.go() used where the user needs to be able to press back (it replaces the stack, not pushes onto it)? Does a redirect callback read some external state (an auth flag) without the router’s refreshListenable pointed at whatever notifies when that state changes?
  • The question to ask: for this exact route table, in this exact declaration order, which route actually matches /pets/new first — and if the value this redirect reads changes without a navigation event happening at the same time, does anything tell the router to re-evaluate?
  • Which lesson explains it: the AI Bug Catalog’s Navigation group, proven with a real route-order misroute test, a real stack-depth comparison between go/push, and a real stuck-on-login redirect test; Declarative Routing and Navigator and Routes for both mechanisms in full.

Code that must run identically on mobile, desktop, and web, or that offloads work to a separate isolate, is where an AI assistant’s mental model of “one Dart, one runtime” breaks down most visibly — some of these bugs are compile-time, most are silent until a specific platform or a specific captured value is involved.

  • What to look for: does dart:io/Platform.isIOS/File get used unconditionally in code that also has to build for web (it compiles fine for web — every member just throws UnsupportedError the moment it actually runs)? Does a MethodChannel.invokeMethod call assume a native handler is registered on every platform the app ships to? Does a compute() closure capture a BuildContext, a Zone, or anything else tied to this isolate’s own binding (plain data and even some closures now cross the isolate boundary fine in current Dart — but anything reachable from the Element tree still cannot)?
  • The question to ask: if this exact function runs on the one platform (or the one isolate) it has never actually been tested on, does every value it touches still mean something there — or does it only work because every environment this was written and tested against happened to share an assumption the code never states?
  • Which lesson explains it: the AI Bug Catalog’s Platform group, proven with a real flutter build web --release that compiles dart:io cleanly, a real MissingPluginException, and a real Illegal argument in isolate message: object is unsendable captured for a BuildContext-capturing compute() closure; Platform Channels and Isolates and compute() for both mechanisms in full.

The last mile is checking whether the project’s own tooling would actually have caught any of the above — a green flutter analyze proves far less than it looks like it proves, and a print() statement that “obviously” gets stripped from a release build does not.

  • What to look for: which lint package is actually included (flutter_lints 6 is narrower than its reputation — no general prefer_const_constructors, for instance), and is riverpod_lint/custom_lint actually resolving together at the pinned versions (frequently not, as of this course’s own dependency check)? Does anything in the release pipeline strip print() (nothing does, by default — it survives verbatim into a release web bundle, proven for this module)? Is flutter build web --wasm ever exercised as a portability gate, or only the default JS-compiled build?
  • The question to ask: if this exact project’s CI ran nothing but flutter analyze and called it done, which of this module’s proven bugs would still have shipped — and does the project’s analysis_options.yaml actually enable the rules its own README claims it does?
  • Which lesson explains it: the AI Bug Catalog’s Tooling & release group, proven with a real flutter_lints 6 lint-package diff and a real print() string found verbatim inside a release main.dart.js; Verification Tools for the full ladder of tools and what each one actually proves; Testing, Build and Release, and Production Concerns for each topic in full.
  • The AI Bug Catalog — specific patterns, each with the code AI typically writes, the real output captured fresh for this module (an analyzer diagnostic, a flutter test failure, or a runtime error), and the fix.
  • Review Checklist — the same patterns as a one-page, eight-group checklist, in this reading order, each line linking the catalog entry and the tool that proves it.
  • Verification Tools — the real ladder: flutter analyze, analysis_options.yaml strict modes, flutter_lints/riverpod_lint/custom_lint (with a real, current version-resolution finding), dart fix --dry-run, flutter test (--coverage, golden tests), flutter build web --wasm as a portability gate, DevTools, DartPad, and this course’s own harness.
  • Three worked reviews — Worked Review 1, Worked Review 2, Worked Review 3 — applying this reading order to full, AI-generated features (a Riverpod-codegen todo app with go_router’s StatefulShellRoute, a media app spanning slivers/isolates/chunked upload, and a cross-platform app spanning dart:io/web, a platform channel, and a release build). These lessons are written and maintained separately from this one; if a link below 404s while that work is still in progress, the reading order and the catalog above stand on their own regardless.
  • Every earlier Phase 3 lesson in this course already has its own ## Reading AI-generated code section with a <SpotTheBug> — this module’s catalog does not repeat those, it groups a new, broader set of patterns around the same reading order. Start from the course index if you want the full lesson-by-lesson path instead.
Reading order for Flutter code you did not write:
1. pubspec & SDK constraints -> which Flutter/Dart, which package versions?
2. State approach -> Provider/Riverpod/Bloc -- wired up at all?
3. Widget tree shape -> keys on reordered children, const, GlobalKey
4. Lifecycle & async gaps -> setState after dispose, setState during build
5. Layout constraints -> unbounded height, Expanded outside a Flex
6. Navigation -> go_router route order, go vs push, redirect
7. Platform & isolates -> dart:io on web, MissingPluginException, compute()
8. Tests, lints & release -> which lints actually run, print in release, --wasm
"It renders fine on the emulator" answers almost none of these -- most of this
module's catalog entries render cleanly and break on rotate, rebuild, reorder,
navigate, or release instead.
  • “If flutter analyze is clean, the code is correct.” Proven throughout this module’s catalog: a ConsumerWidget with no ProviderScope above it, a ListView about to overflow its constraints, and a compute() closure about to fail at runtime all analyze perfectly cleanly — flutter analyze proves types and imports resolve, never that a runtime invariant holds.
  • “It rendered correctly once, so the bug is fixed.” The opposite is closer to true for most of this module: a missing mounted guard, a missing key, and a route-order mistake all render correctly on the very first pass — they only show their cost on a rotation, a reorder, a second navigation, or a widget disposed mid-flight.
  • “AI-generated Flutter code just needs a newer prompt.” The problem is not staleness alone — StateProvider genuinely still exists in Riverpod 3, just behind a different import; the code that fails is often technically describing a real, current API, just not the one it thinks it’s using.
  • “A silent success is safer than an error.” The opposite is closer to true in this catalog: dart:io on web compiles without a single warning and only throws the moment a Platform.isIOS check actually executes in a browser — a build that had simply refused to compile would have been the safer failure.
Why does this module check pubspec and SDK constraints before checking anything about state management or widgets?
This is the real flutter analyze output for a file with a Text widget that is missing const and could have it. What does a clean result actually prove here?
Analyzing h1_prefer_const.dart...
No issues found!
Why does this module say a widget that 'renders fine on the emulator' is not evidence the lifecycle/async-gap group is clean?
Which reading-order step specifically asks whether a compute() closure captures anything reachable from the Element tree, rather than just 'does this closure capture any outer variable'?
Why does this module treat a route table's DECLARATION ORDER as something to check explicitly, rather than trusting go_router to always match the 'most specific' route?