In Flutter, a single widget’s local state lives in State and changes through setState, while data needed by several screens moves into a ChangeNotifier model shared with the provider package: context.watch in build, context.read in handlers. For navigation there is Navigator (push/pop, data through the constructor, a result through pop), and for addresses and deep links the docs recommend go_router: a /notes/:id route, context.go and context.push.
After this you will be able to
- tell local state from app state and lift state to a common parent through parameters and callbacks
- move shared data into a
ChangeNotifier, provide it withChangeNotifierProviderand choose correctly betweenwatchandread - open a screen with
Navigator.push, pass data through the constructor and get a result frompop - set up
go_routerwith a/notes/:idpath parameter, tellgofrompushand show loading withFutureBuilder
Know beforehand
Verified with
Flutter 3.47.7 (stable)Dart 3.13.5provider 6.1.5+1go_router 18.0.2
Checked against docs: October 11, 2026
Contents
Local and shared state: where data should live
In the lesson “Flutter and Dart: your first app” the booking button kept its own state in a State object with setState. That is enough while only one widget needs the data. The Flutter docs split state into two kinds. Ephemeral state (also called UI state or local state) fits neatly in a single widget: the selected tab, the current PageView page, animation progress. App state is needed by many parts of the UI and often has to survive restarts: a shopping cart, login info, unread notifications.
There is no clear-cut rule, and the docs say so directly: you can manage all state in an app with State and setState, and the same value sometimes has to be refactored from one kind into the other. A practical guide: if more than one screen reads or changes a value, it is app state.
The first step from local to shared state is lifting state up: the value moves to the nearest common parent, and the children receive it through the constructor and report user actions through callbacks. In the example below OrderScreen keeps the cup count, while CupsStepper remembers nothing: it shows value and calls onChanged.
import 'package:flutter/material.dart';
/// The parent owns the state and passes it down to its children.
class OrderScreen extends StatefulWidget {
const OrderScreen({super.key});
@override
State<OrderScreen> createState() => _OrderScreenState();
}
class _OrderScreenState extends State<OrderScreen> {
int _cups = 1;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
CupsStepper(
value: _cups,
onChanged: (value) => setState(() => _cups = value),
),
Text('Total cups: $_cups'),
],
);
}
}
/// A stateless child: shows the value and reports user actions.
class CupsStepper extends StatelessWidget {
const CupsStepper({super.key, required this.value, required this.onChanged});
final int value;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Fewer',
icon: const Icon(Icons.remove),
// null disables the button
onPressed: value > 1 ? () => onChanged(value - 1) : null,
),
Text('$value'),
IconButton(
tooltip: 'More',
icon: const Icon(Icons.add),
onPressed: () => onChanged(value + 1),
),
],
);
}
}ChangeNotifier and provider: state for several screens
When the list of notes is needed by the list screen, the details screen and the add form, threading it through every screen’s constructor gets awkward. The Flutter docs list the options: setState for local state; ValueNotifier and InheritedNotifier using only SDK APIs; InheritedWidget, the low-level mechanism that provider and many other packages use under the hood; and community packages.
The official recommendation for getting started is the provider package. The “Simple app state management” page says that if you are new to Flutter and have no strong reason to choose another approach (Redux, Rx, hooks), this is probably where you should start — it is easy to understand and needs little code. The architecture guide adds: separating the UI into Views and ViewModels (MVVM) is “strongly recommended”, and so is provider for dependency injection; ChangeNotifier for widget updates is a conditional recommendation — a convenient built-in option, but the choice of approach comes down to team preference.
The model is a ChangeNotifier
The class holds data and business logic and calls
notifyListeners()after every change — the signal for listeners to rebuild.A provider above the tree
ChangeNotifierProvidercreates the model once, hands it to all descendants and callsdispose()itself when the model is no longer needed.Subscribe in build
context.watch<NotesModel>()returns the model and rebuilds the widget on everynotifyListeners().Act in a handler
context.read<NotesModel>()returns the model without subscribing — foronPressed,initStateand other places outsidebuild.
flutter create notes_app
cd notes_app
flutter pub add provider go_routerflutter pub add writes current compatible versions into pubspec.yaml.class Note {
const Note({required this.id, required this.title, this.body = ''});
final String id;
final String title;
final String body;
}import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'models/note.dart';
/// Shared app state: the list of notes.
class NotesModel extends ChangeNotifier {
final List<Note> _notes = [];
bool _loaded = false;
int _nextId = 1;
/// Outside code can only read the list — changes go through the model’s methods.
UnmodifiableListView<Note> get notes => UnmodifiableListView(_notes);
Note? byId(String id) {
for (final note in _notes) {
if (note.id == id) return note;
}
return null;
}
/// Simulates a server request; calling it again does nothing.
Future<void> load() async {
if (_loaded) return;
await Future<void>.delayed(const Duration(milliseconds: 300));
_notes.addAll(const [
Note(id: '1', title: 'Buy coffee', body: 'Espresso beans, 1 kg'),
Note(id: '2', title: 'Call the garage', body: 'Book a car service'),
]);
_nextId = 3;
_loaded = true;
notifyListeners();
}
void add(String title, String body) {
_notes.add(Note(id: '${_nextId++}', title: title, body: body));
notifyListeners(); // listeners will rebuild
}
void remove(String id) {
_notes.removeWhere((note) => note.id == id);
notifyListeners();
}
}import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'notes_model.dart';
import 'router.dart';
void main() {
runApp(
// The model is created once above the whole app and is available to every screen.
ChangeNotifierProvider(
create: (context) => NotesModel(),
child: const NotesApp(),
),
);
}
class NotesApp extends StatefulWidget {
const NotesApp({super.key});
@override
State<NotesApp> createState() => _NotesAppState();
}
class _NotesAppState extends State<NotesApp> {
// Create the router once, not in build.
final GoRouter _router = createRouter();
@override
void dispose() {
_router.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Notes',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
routerConfig: _router,
);
}
}context.watch<T>()
- What it does
- Returns
Tand subscribes the widget to changes - Where to call it
- In
build
context.read<T>()
- What it does
- Returns
Twithout subscribing - Where to call it
- In handlers and
initState; not inbuild
context.select<T, R>(…)
- What it does
- Subscribes to part of the data only
- Where to call it
- In
build, when you need one value
Consumer<T>
- What it does
- A widget that rebuilds only its
builder - Where to call it
- Deep in the tree, to narrow rebuilds
Navigator: push, pop and passing data
Navigator keeps screens in a stack. Navigator.of(context).push(…) puts a new screen on top, pop() removes it. The screen is wrapped in a route: MaterialPageRoute adds a Material-style transition. Data goes to the new screen as an ordinary constructor parameter — the compiler checks the type. A result comes back through pop(value): push returns a Future that completes with that value.
import 'package:flutter/material.dart';
/// Navigator without packages: data goes in via the constructor, comes back via pop.
class TagButton extends StatefulWidget {
const TagButton({super.key});
@override
State<TagButton> createState() => _TagButtonState();
}
class _TagButtonState extends State<TagButton> {
String? _tag;
Future<void> _pickTag() async {
final tag = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (context) =>
const TagPickerScreen(tags: ['Work', 'Home', 'Shopping']),
),
);
if (!mounted) return;
if (tag != null) setState(() => _tag = tag);
}
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: _pickTag,
child: Text(_tag == null ? 'Choose a tag' : 'Tag: $_tag'),
);
}
}
class TagPickerScreen extends StatelessWidget {
const TagPickerScreen({super.key, required this.tags});
final List<String> tags;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Tag')),
body: ListView(
children: [
for (final tag in tags)
ListTile(
title: Text(tag),
onTap: () => Navigator.of(context).pop(tag),
),
],
),
);
}
}After await, the screen may already be gone, so check mounted before calling setState or using context. If you forget, the use_build_context_synchronously lint from flutter_lints warns about using a BuildContext across an async gap, and setState on a closed screen fails with setState() called after dispose().
go_router: routes, parameters and deep links
Imperative Navigator suits small apps. When screens must open from an address — a web app with direct links, or deep links from notifications and emails on Android and iOS — the docs recommend the Router API, usually through the go_router package. The architecture guide calls it the preferred way to write 90% of Flutter apps. The package is published by the flutter.dev team; pub.dev marks it as feature-complete: no new features are planned, and work focuses on bug fixes and stability.
import 'package:go_router/go_router.dart';
import 'screens/new_note_screen.dart';
import 'screens/note_details_screen.dart';
import 'screens/notes_list_screen.dart';
GoRouter createRouter() {
return GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const NotesListScreen(),
// Nested routes open on top of the parent: /notes/2, /new.
routes: [
GoRoute(
path: 'notes/:id',
builder: (context, state) =>
NoteDetailsScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: 'new',
builder: (context, state) => const NewNoteScreen(),
),
],
),
],
);
}A route is a path plus a builder function. A segment with a colon, :id, is a path parameter; its value is in state.pathParameters['id']. Nested routes (routes inside a parent) open on top of the parent screen, so going to /notes/2 builds the list → details stack and shows a back button — one of the tests checks this by simulating a deep link.
context.go('/notes/2')
- What it does
- Replaces the screen stack with the one matching the address
- When to use it
- Regular navigation; the address and the stack always match
context.push('/new')
- What it does
- Puts a screen on top of the current stack
- When to use it
- When you need a result:
await context.push<bool>(…)
context.pop(true)
- What it does
- Closes the screen and returns a value
- When to use it
- Forms, pickers, confirmations
extra:
- What it does
- Passes an object along with navigation
- When to use it
- Rarely: in the browser the data is serialized; passing an id in the path is more robust
The go_router docs warn that imperative navigation (push) can cause issues with browser history, so go is the main tool and push is kept for cases where you need a result. Version 18 of the package requires Flutter 3.44 and Dart 3.12 or newer.
The Notes screens and async loading
The list loads notes when it opens. While data is on its way you need a spinner, and on failure a message. FutureBuilder does this: it rebuilds its builder as the Future moves from waiting (ConnectionState.waiting) to completion (done) with data or an error. The key rule from the API docs: the Future must not be created inside build — obtain it earlier, for example in initState. Otherwise every rebuild starts loading again.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../notes_model.dart';
class NotesListScreen extends StatefulWidget {
const NotesListScreen({super.key});
@override
State<NotesListScreen> createState() => _NotesListScreenState();
}
class _NotesListScreenState extends State<NotesListScreen> {
late final Future<void> _loading;
@override
void initState() {
super.initState();
// Create the Future once. In build, loading would restart
// on every rebuild.
_loading = context.read<NotesModel>().load();
}
Future<void> _openNewNote() async {
final saved = await context.push<bool>('/new');
if (!mounted) return; // the screen may have closed while we waited
if (saved == true) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Note added')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Notes')),
body: FutureBuilder<void>(
future: _loading,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Failed to load: ${snapshot.error}'));
}
// watch: rebuild the list on every notifyListeners().
final notes = context.watch<NotesModel>().notes;
if (notes.isEmpty) {
return const Center(child: Text('No notes yet'));
}
return ListView.builder(
itemCount: notes.length,
itemBuilder: (context, index) {
final note = notes[index];
return ListTile(
title: Text(note.title),
subtitle: Text(
note.body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () => context.go('/notes/${note.id}'),
);
},
);
},
),
floatingActionButton: FloatingActionButton(
tooltip: 'New note',
onPressed: _openNewNote,
child: const Icon(Icons.add),
),
);
}
}import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../notes_model.dart';
class NoteDetailsScreen extends StatelessWidget {
const NoteDetailsScreen({super.key, required this.id});
final String id; // parameter from the /notes/:id path
@override
Widget build(BuildContext context) {
final note = context.watch<NotesModel>().byId(id);
if (note == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('Note not found')),
);
}
return Scaffold(
appBar: AppBar(
title: Text(note.title),
actions: [
IconButton(
tooltip: 'Delete',
icon: const Icon(Icons.delete_outline),
onPressed: () {
// read: the handler needs the model, not a subscription to it.
final model = context.read<NotesModel>();
context.pop();
model.remove(id);
},
),
],
),
body: Padding(padding: const EdgeInsets.all(16), child: Text(note.body)),
);
}
}import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../notes_model.dart';
class NewNoteScreen extends StatefulWidget {
const NewNoteScreen({super.key});
@override
State<NewNoteScreen> createState() => _NewNoteScreenState();
}
class _NewNoteScreenState extends State<NewNoteScreen> {
final _title = TextEditingController();
final _body = TextEditingController();
@override
void dispose() {
_title.dispose(); // release the controllers together with the screen
_body.dispose();
super.dispose();
}
void _save() {
final title = _title.text.trim();
if (title.isEmpty) return;
context.read<NotesModel>().add(title, _body.text.trim());
context.pop(true); // return a result to the screen that opened this one
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('New note')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _title,
decoration: const InputDecoration(labelText: 'Title'),
),
const SizedBox(height: 12),
TextField(
controller: _body,
decoration: const InputDecoration(labelText: 'Text'),
maxLines: 4,
),
const SizedBox(height: 24),
FilledButton(onPressed: _save, child: const Text('Save')),
],
),
);
}
}Notice the split: the list of notes is app state and lives in NotesModel, while the text in the form fields is the screen’s local state in a TextEditingController. The details screen receives only an id and looks the note up in the model, so it does not matter how it was opened — from the list or via the /notes/2 address. A limitation of this teaching version: if the address is opened before the notes have loaded, the screen shows “Note not found” until loading finishes. In a production app the model keeps a loading flag, and the details screen shows a spinner meanwhile.
Checking the result: widget tests
The tests follow the user’s path: see the loading spinner, wait for the list, open a note, go back, add a new one and delete an old one. tester.pump(Duration) advances time to simulate loading, and pumpAndSettle() waits for transition animations to finish. Do not call pumpAndSettle() while a CircularProgressIndicator is spinning: the endless animation never lets it settle.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:notes_app/basics/lifting.dart';
import 'package:notes_app/basics/navigator_demo.dart';
import 'package:notes_app/main.dart';
import 'package:notes_app/notes_model.dart';
import 'package:notes_app/screens/notes_list_screen.dart';
Widget app() => ChangeNotifierProvider(
create: (context) => NotesModel(),
child: const NotesApp(),
);
void main() {
testWidgets('state lifted to the parent', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: OrderScreen())),
);
for (var i = 0; i < 2; i++) {
await tester.tap(find.byTooltip('More'));
await tester.pump(); // rebuild: the button receives the new value
}
expect(find.text('Total cups: 3'), findsOneWidget);
expect(find.text('3'), findsOneWidget); // the child shows the same value
});
testWidgets('Navigator: data there and back', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: TagButton())),
);
await tester.tap(find.text('Choose a tag'));
await tester.pumpAndSettle(); // wait for the transition animation
await tester.tap(find.text('Home'));
await tester.pumpAndSettle();
expect(find.text('Tag: Home'), findsOneWidget);
});
testWidgets('list → details → back → new note', (tester) async {
await tester.pumpWidget(app());
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pump(const Duration(milliseconds: 300)); // “loading”
await tester.pump();
expect(find.text('Buy coffee'), findsOneWidget);
await tester.tap(find.text('Buy coffee'));
await tester.pumpAndSettle();
expect(find.text('Espresso beans, 1 kg'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('New note'));
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextField).first,
'Pay the internet bill',
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('Pay the internet bill'), findsOneWidget);
expect(find.text('Note added'), findsOneWidget);
});
testWidgets('going to /notes/2 builds the list → details stack', (
tester,
) async {
await tester.pumpWidget(app());
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
final context = tester.element(find.byType(NotesListScreen));
GoRouter.of(context).go('/notes/2'); // as if a deep link were opened
await tester.pumpAndSettle();
expect(find.text('Book a car service'), findsOneWidget);
expect(find.byType(BackButton), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Buy coffee'), findsOneWidget);
});
testWidgets('deleting on the details screen', (tester) async {
await tester.pumpWidget(app());
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.tap(find.text('Call the garage'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Delete'));
await tester.pumpAndSettle();
expect(find.text('Call the garage'), findsNothing);
expect(find.text('Buy coffee'), findsOneWidget);
});
}$ flutter analyze
Analyzing notes_app...
No issues found! (ran in 7.4s)
$ flutter test
00:00 +0: state lifted to the parent
00:00 +1: Navigator: data there and back
00:00 +2: list → details → back → new note
00:00 +3: going to /notes/2 builds the list → details stack
00:00 +4: deleting on the details screen
00:01 +5: All tests passed!flutter build web also succeeds for this project.- on launch a spinner appears, then two notes a moment later
- tapping a note opens details with a back arrow
- after “Save” the new note appears in the list and “Note added” is shown
- after deletion the note disappears from the list
Common mistakes and exercises
ProviderNotFoundException
- Cause
- The provider is not above the widget looking for it, or a test forgot to wrap the app
- Fix
- Put
ChangeNotifierProvideraboveMaterialApp, in tests too
The screen does not update after the model changes
- Cause
- No
notifyListeners(), orreadused instead ofwatch - Fix
- Call
notifyListeners()in every mutating method; usewatchinbuild
Loading repeats forever
- Cause
- The
FutureBuilderfuture is created inbuild - Fix
- Create it in
initStateand keep it in a field
setState() called after dispose()
- Cause
setStateafterawaitwhen the screen is already closed- Fix
- Check
if (!mounted) return;
await context.push returns no result
- Cause
- Navigation used
go, which replaces the stack - Fix
- Use
pushfor a screen with a result and close it withpop(value)
Next on the Flutter + Dart path: a recipe for a validated form with a widget test, then testing mobile apps and publishing to the stores.
How this was verified4 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.