In a growing Flutter notes app, how do you decide where each piece of state lives, and when does passing values and callbacks down stop scaling?
answer
- readers, writers, lifetime
- the lowest owner that covers them
- widgets forwarding what they never use
- routes are siblings, not children
- push arguments are snapshots
basics
~20 sPut each value in the lowest widget above all its readers and writers that lives as long as the value must. Passing values and callbacks down stops scaling when middle widgets only forward them, or when several routes need the same live data.
solid answer
~50 sFor each piece of state, list who reads it, who changes it and how long it must live, then place it in the **lowest owner** that covers all three: a toggle in its widget, a screen's filter in the screen, the signed-in user above the `Navigator` because every route needs it. Constructor-and-callback passing works while the owner is a level or two above its readers. It stops scaling when intermediate widgets carry parameters they never use, when every new reader means editing a chain of constructors, and especially across **routes**: routes are siblings in the `Navigator`'s `Overlay`, not children of the screen that pushed them, so a value passed as a push argument is a snapshot that the pushing screen's later `setState` cannot update. At that point the value needs an owner above the `Navigator` that descendants can look up — inherited widgets, notifiers or a state library.
code
dart · 42 linesimport 'package:flutter/material.dart';
// Snapshot trap: the editor gets the list as it was at push time.
class NotesListScreen extends StatefulWidget {
const NotesListScreen({super.key});
@override
State<NotesListScreen> createState() => _NotesListScreenState();
}
class _NotesListScreenState extends State<NotesListScreen> {
List<String> _titles = const ['Groceries', 'Vet visit'];
void _openEditor() {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => EditorScreen(titles: _titles), // a sibling route, not a child
),
);
}
void _addNote(String title) => setState(() => _titles = [..._titles, title]);
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView(children: [for (final t in _titles) ListTile(title: Text(t), onTap: _openEditor)]),
floatingActionButton: FloatingActionButton(
onPressed: () => _addNote('New note'),
child: const Icon(Icons.add),
),
);
}
}
class EditorScreen extends StatelessWidget {
const EditorScreen({super.key, required this.titles});
final List<String> titles;
@override
Widget build(BuildContext context) => Scaffold(body: Text('${titles.length} notes'));
}go deeper
Know the three questions — who reads it, who changes it, how long it lives — and that shared data must sit above every reader.
Explain why routes are siblings under the Navigator and why push arguments are snapshots, and name the signs that drilling has gone too far.
Audit a growing app for state held too high or too low, and plan moves above the Navigator with lookups instead of ever-longer constructor chains.
Define how far state may be lifted before a shared owner is introduced, balancing explicit data flow against coupling and rebuild scope across teams.
## Start from three questions Every piece of state in a notes app — the password toggle, the search query, the selected note, the notes list, the signed-in user — can be placed by answering three questions: 1. **Who reads it?** Every widget whose output depends on it. 2. **Who changes it?** Every widget or service that triggers an update. 3. **How long must it live?** Until the widget leaves the tree, while a screen is open, for the whole session, or across restarts. The owner is the **lowest point in the tree that sits above every reader and writer and lives at least as long as the value must**. Going lower breaks sharing; going higher widens rebuilds and couples unrelated code. | Piece of state | Readers and writers | Lifetime | Lowest sensible owner | |---|---|---|---| | Password show/hide | the password field | while visible | the field's `State` | | Search query on the list | search bar, list | while the list screen is open | the list screen's `State` | | Note being edited | editor widgets | while the editor route is open | the editor screen's `State` | | Notes list | list screen, editor, pinned header | session, synced | above the `Navigator` | | Signed-in user | every screen, the API layer | across restarts | above the `Navigator` | ## Why routes change the picture A Flutter-specific trap makes "lift to the common ancestor" harder than it looks for multi-screen state. The `Navigator` builds an **`Overlay`** and places each route's content in it as its own entry. A route pushed from the list screen is therefore a **sibling** of that screen inside the overlay, not a descendant of it. Two consequences: - The nearest common ancestor of the list screen and the editor route is the `Navigator` itself or something above it — in practice, above `MaterialApp`'s navigator. - Passing the notes list as a constructor argument when pushing the editor hands over a **snapshot**. When the list screen later calls `setState`, only its own subtree rebuilds; the editor route, a sibling, keeps the old value. This is why data shared across screens usually ends up above the `Navigator`, where every route can reach it. ## When drilling stops scaling **Prop drilling** — threading values and callbacks through constructors — is the right tool for short distances and is easy to follow. Warning signs that it has gone too far: - **Pass-through parameters.** Widgets accept `user`, `onSignOut` or `notes` only to hand them to a child. - **Constructor churn.** Adding one reader deep in the tree means editing four or five constructors above it. - **Callback chains.** An event travels up through several `ValueChanged` hops before reaching the owner. - **Cross-route needs.** Several routes need the same live value. - **Wide rebuilds.** The owner sits so high that each `setState` rebuilds screens unrelated to the change. None of these is a hard limit; they are costs that grow with the app. ## What comes next When the signs appear, the value still needs one owner — but readers should find it **by looking it up** rather than receiving it through every constructor. Flutter's own answer is the inherited-widget mechanism, which lets any descendant depend on data provided above it; notifier objects such as `ChangeNotifier` let an owner announce changes without rebuilding everything; and state libraries package those ideas with testing and lifecycle support. Each has its own trade-offs, covered separately. The decision here is only *where* the owner sits and *when* drilling has stopped paying for itself. ## A practical sequence for a growing app 1. Keep new state **ephemeral** in the widget that uses it. 2. When a sibling needs it, **lift** it to the nearest common ancestor and pass values and callbacks down. 3. When pass-through parameters or cross-route needs appear, **move the owner above the `Navigator`** and expose it through a lookup mechanism. 4. Derive what can be computed — counts, filtered lists — in `build` instead of storing it a second time. Following the sequence keeps each piece of state no more global than it has to be, which keeps screens independent and rebuilds proportionate.
- Why doesn't setState in the list screen update an editor route it pushed with the notes as an argument?The `Navigator` hosts each route as its own entry in an `Overlay`, so the editor is a sibling of the list screen, not a descendant. `setState` rebuilds only the list screen's subtree, and the editor keeps the constructor argument it received at push time. Shared, changing data belongs in an owner above the `Navigator` that both routes can depend on.
- Is prop drilling itself a bad practice in Flutter?No. Passing values and callbacks through constructors is explicit, easy to test and the right choice over short distances. It becomes a cost when widgets forward parameters they never use, when each new reader means editing many constructors, or when routes need live shared data. Those signs, not a fixed depth, justify a lookup mechanism.
saying these in an interview costs you the question
- A route pushed from a screen is a child of that screen in the widget tree.
- Arguments passed when pushing a route stay in sync with the pusher's later setState.
- All state should be global from day one so it never has to move.
- Prop drilling is always wrong in Flutter, even one level deep.
- State shared by two routes can live in whichever screen opened first.