How to buildOctober 11, 20268 min read
Flutter + DartStep 3 of 6

Flutter: state and navigation

In short

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.

TutorialLevel: IntermediateFlutterPractice: ~1.5–3 h

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 with ChangeNotifierProvider and choose correctly between watch and read
  • open a screen with Navigator.push, pass data through the constructor and get a result from pop
  • set up go_router with a /notes/:id path parameter, tell go from push and show loading with FutureBuilder

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
  1. 01Local and shared state: where data should live
  2. 02ChangeNotifier and provider: state for several screens
  3. 03Navigator: push, pop and passing data
  4. 04go_router: routes, parameters and deep links
  5. 05The Notes screens and async loading
  6. 06Checking the result: widget tests
  7. 07Common mistakes and exercises

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.

lib/basics/lifting.dart
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),
        ),
      ],
    );
  }
}
Lifting state: the parent is a StatefulWidget, the child is a StatelessWidget with value and onChanged.

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.

  1. The model is a ChangeNotifier

    The class holds data and business logic and calls notifyListeners() after every change — the signal for listeners to rebuild.

  2. A provider above the tree

    ChangeNotifierProvider creates the model once, hands it to all descendants and calls dispose() itself when the model is no longer needed.

  3. Subscribe in build

    context.watch<NotesModel>() returns the model and rebuilds the widget on every notifyListeners().

  4. Act in a handler

    context.read<NotesModel>() returns the model without subscribing — for onPressed, initState and other places outside build.

bash
flutter create notes_app
cd notes_app
flutter pub add provider go_router
The project and two packages. flutter pub add writes current compatible versions into pubspec.yaml.
lib/models/note.dart
class Note {
  const Note({required this.id, required this.title, this.body = ''});

  final String id;
  final String title;
  final String body;
}
lib/notes_model.dart
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();
  }
}
The model exposes a read-only list: it can only be changed through methods that call notifyListeners().
lib/main.dart
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,
    );
  }
}
The provider sits above the whole app; MaterialApp.router receives the router from the next section.

context.watch<T>()

What it does
Returns T and subscribes the widget to changes
Where to call it
In build

context.read<T>()

What it does
Returns T without subscribing
Where to call it
In handlers and initState; not in build

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.

lib/basics/navigator_demo.dart
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),
            ),
        ],
      ),
    );
  }
}
Picking a tag: the tag list goes into the TagPickerScreen constructor, the chosen tag comes back through pop.

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.

lib/router.dart
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(),
          ),
        ],
      ),
    ],
  );
}
App routes: the list at /, details at /notes/:id, the form at /new.

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.

lib/screens/notes_list_screen.dart
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),
      ),
    );
  }
}
The list: the Future is created in initState, the list subscribes to the model with watch, and a new note opens with push and returns a result.
lib/screens/note_details_screen.dart
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)),
    );
  }
}
Details: the id comes from the address and the note from the model; if it is missing, a clear message instead of a crash.
lib/screens/new_note_screen.dart
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')),
        ],
      ),
    );
  }
}
The form: TextEditingController is released in dispose; after saving, the screen closes with the result true.

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.

test/widget_test.dart
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);
  });
}
Five widget tests: lifted state, Navigator with a result, the full flow, navigating by address and deletion.
terminal
$ 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!
Actual result in the verification project (Flutter 3.47.7). 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 ChangeNotifierProvider above MaterialApp, in tests too

The screen does not update after the model changes

Cause
No notifyListeners(), or read used instead of watch
Fix
Call notifyListeners() in every mutating method; use watch in build

Loading repeats forever

Cause
The FutureBuilder future is created in build
Fix
Create it in initState and keep it in a field

setState() called after dispose()

Cause
setState after await when 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 push for a screen with a result and close it with pop(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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.