Локальное состояние одного виджета во Flutter хранят в State и меняют через setState, а данные, нужные нескольким экранам, выносят в модель на ChangeNotifier и раздают через пакет provider: context.watch в build, context.read в обработчиках. Для переходов есть Navigator (push/pop, данные через конструктор, результат через pop), а для адресов и глубоких ссылок документация рекомендует go_router: маршрут /notes/:id, context.go и context.push.
После материала вы сможете
- отличить локальное состояние от состояния приложения и поднять состояние в общего родителя через параметры и callback
- вынести общие данные в
ChangeNotifier, подключить его черезChangeNotifierProviderи правильно выбратьwatchилиread - открыть экран через
Navigator.push, передать данные в конструктор и получить результат изpop - настроить
go_routerс параметром пути/notes/:id, различатьgoиpushи показать загрузку черезFutureBuilder
Что знать заранее
Проверено на
Flutter 3.47.7 (stable)Dart 3.13.5provider 6.1.5+1go_router 18.0.2
Сверено с документацией: 11 октября 2026 г.
Содержание
Локальное и общее состояние: где хранить данные
В уроке «Flutter и Dart: первое приложение» кнопка записи хранила своё состояние сама — в объекте State и через setState. Этого достаточно, пока данные нужны одному виджету. Документация Flutter делит состояние на два вида. Локальное (ephemeral, его ещё называют UI-состоянием) аккуратно умещается в одном виджете: выбранная вкладка, текущая страница PageView, ход анимации. Состояние приложения (app state) нужно многим частям интерфейса и часто должно переживать перезапуск: корзина, данные входа, непрочитанные уведомления.
Чёткой границы нет, и документация говорит об этом прямо: состоянием можно управлять через State и setState во всём приложении, а одно и то же значение со временем приходится переносить из одного вида в другой. Практический ориентир: если значение читает или меняет больше одного экрана — это состояние приложения.
Первый шаг от локального состояния к общему — подъём состояния (lifting state up): значение переносят в ближайшего общего родителя, а дети получают его через конструктор и сообщают о действиях через функции обратного вызова (callback). В примере ниже счётчик чашек хранит OrderScreen, а CupsStepper ничего не помнит: показывает value и вызывает onChanged.
import 'package:flutter/material.dart';
/// Родитель владеет состоянием и передаёт его детям.
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('Итого чашек: $_cups'),
],
);
}
}
/// Ребёнок без своего состояния: показывает значение и сообщает о действиях.
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: 'Меньше',
icon: const Icon(Icons.remove),
// null делает кнопку неактивной
onPressed: value > 1 ? () => onChanged(value - 1) : null,
),
Text('$value'),
IconButton(
tooltip: 'Больше',
icon: const Icon(Icons.add),
onPressed: () => onChanged(value + 1),
),
],
);
}
}ChangeNotifier и provider: состояние для нескольких экранов
Когда список записей нужен экрану списка, экрану деталей и форме добавления, протягивать его через конструкторы каждого экрана неудобно. Документация Flutter перечисляет варианты: setState для локального состояния; ValueNotifier и InheritedNotifier — только средствами SDK; InheritedWidget — низкоуровневый механизм, на котором построены provider и многие другие пакеты; и пакеты сообщества.
Официальная рекомендация для начала — пакет provider. Страница «Simple app state management» говорит: если вы новичок во Flutter и у вас нет веской причины выбрать другой подход (Redux, Rx, hooks), начинать стоит с него — он простой и требует мало кода. Руководство по архитектуре добавляет: разделять интерфейс на View и ViewModel (MVVM) — «настоятельно рекомендуется», provider для внедрения зависимостей — тоже; ChangeNotifier для обновления виджетов помечен как условная рекомендация: удобный встроенный способ, но выбор подхода — дело предпочтений команды.
Модель — ChangeNotifier
Класс хранит данные и бизнес-логику, а после каждого изменения вызывает
notifyListeners()— сигнал подписчикам перерисоваться.Провайдер над деревом
ChangeNotifierProviderсоздаёт модель один раз, отдаёт её всем потомкам и сам вызываетdispose(), когда модель больше не нужна.Подписка в build
context.watch<NotesModel>()возвращает модель и перерисовывает виджет при каждомnotifyListeners().Действие в обработчике
context.read<NotesModel>()возвращает модель без подписки — дляonPressed,initStateи других мест внеbuild.
flutter create notes_app
cd notes_app
flutter pub add provider go_routerflutter pub add записывает актуальные совместимые версии в 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';
/// Общее состояние приложения: список записей.
class NotesModel extends ChangeNotifier {
final List<Note> _notes = [];
bool _loaded = false;
int _nextId = 1;
/// Снаружи список можно только читать — менять только через методы модели.
UnmodifiableListView<Note> get notes => UnmodifiableListView(_notes);
Note? byId(String id) {
for (final note in _notes) {
if (note.id == id) return note;
}
return null;
}
/// Имитация запроса к серверу; повторный вызов ничего не делает.
Future<void> load() async {
if (_loaded) return;
await Future<void>.delayed(const Duration(milliseconds: 300));
_notes.addAll(const [
Note(id: '1', title: 'Купить кофе', body: 'Зерно для эспрессо, 1 кг'),
Note(id: '2', title: 'Позвонить в сервис', body: 'Записаться на ТО'),
]);
_nextId = 3;
_loaded = true;
notifyListeners();
}
void add(String title, String body) {
_notes.add(Note(id: '${_nextId++}', title: title, body: body));
notifyListeners(); // подписчики перерисуются
}
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(
// Модель создаётся один раз над всем приложением и доступна любому экрану.
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> {
// Роутер создаём один раз, а не в build.
final GoRouter _router = createRouter();
@override
void dispose() {
_router.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Записи',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
),
routerConfig: _router,
);
}
}context.watch<T>()
- Что делает
- Возвращает
Tи подписывает виджет на изменения - Где вызывать
- В
build
context.read<T>()
- Что делает
- Возвращает
Tбез подписки - Где вызывать
- В обработчиках,
initState; не вbuild
context.select<T, R>(…)
- Что делает
- Подписка только на часть данных
- Где вызывать
- В
build, когда нужна одна величина
Consumer<T>
- Что делает
- Виджет, перерисовывающий только свой
builder - Где вызывать
- Глубоко в дереве, чтобы сузить перерисовку
Navigator: push, pop и передача данных
Navigator хранит экраны стопкой. Navigator.of(context).push(…) кладёт новый экран сверху, pop() снимает его. Экран оборачивают в маршрут: MaterialPageRoute добавляет переход в стиле Material. Данные на новый экран передают обычным параметром конструктора — тип проверит компилятор. Результат назад возвращают через pop(значение): push возвращает Future, который завершится этим значением.
import 'package:flutter/material.dart';
/// Navigator без пакетов: данные туда — через конструктор, обратно — через 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: ['Работа', 'Дом', 'Покупки']),
),
);
if (!mounted) return;
if (tag != null) setState(() => _tag = tag);
}
@override
Widget build(BuildContext context) {
return OutlinedButton(
onPressed: _pickTag,
child: Text(_tag == null ? 'Выбрать метку' : 'Метка: $_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('Метка')),
body: ListView(
children: [
for (final tag in tags)
ListTile(
title: Text(tag),
onTap: () => Navigator.of(context).pop(tag),
),
],
),
);
}
}После await экран мог уже закрыться, поэтому перед setState или обращением к context проверяют mounted. Если забыть, линтер use_build_context_synchronously из flutter_lints предупредит об использовании BuildContext после асинхронного разрыва, а setState на закрытом экране упадёт с ошибкой setState() called after dispose().
go_router: маршруты, параметры и глубокие ссылки
Императивный Navigator удобен для небольших приложений. Когда экраны должны открываться по адресу — веб-приложение с прямыми ссылками, глубокие ссылки (deep links) из уведомлений и писем на Android и iOS, — документация советует Router API, обычно через пакет go_router. Руководство по архитектуре называет его предпочтительным способом для 90% Flutter-приложений. Пакет публикует команда flutter.dev; на pub.dev он помечен как feature-complete: новые возможности не планируются, работа идёт над исправлениями и стабильностью.
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(),
// Вложенные маршруты открываются поверх родителя: /notes/2, /new.
routes: [
GoRoute(
path: 'notes/:id',
builder: (context, state) =>
NoteDetailsScreen(id: state.pathParameters['id']!),
),
GoRoute(
path: 'new',
builder: (context, state) => const NewNoteScreen(),
),
],
),
],
);
}Маршрут описывается путём и функцией builder. Сегмент с двоеточием :id — параметр пути, его значение лежит в state.pathParameters['id']. Вложенные маршруты (routes внутри родителя) открываются поверх родительского экрана, поэтому переход на /notes/2 строит стек «список → детали» и показывает кнопку «Назад» — это проверяет один из тестов, имитируя открытие глубокой ссылки.
context.go('/notes/2')
- Что делает
- Заменяет стек экранов на тот, что соответствует адресу
- Когда использовать
- Обычные переходы; адрес и стек всегда совпадают
context.push('/new')
- Что делает
- Кладёт экран поверх текущего стека
- Когда использовать
- Нужно дождаться результата:
await context.push<bool>(…)
context.pop(true)
- Что делает
- Закрывает экран и возвращает значение
- Когда использовать
- Форма, выбор, подтверждение
extra:
- Что делает
- Передаёт объект вместе с переходом
- Когда использовать
- Редко: в браузере данные сериализуются; надёжнее передать id в пути
Документация go_router предупреждает, что императивная навигация (push) может вести себя неожиданно с историей браузера, поэтому основной инструмент — go, а push оставляют для случаев, когда нужен результат. Версия 18 пакета требует Flutter 3.44 и Dart 3.12 или новее.
Экраны «Записей» и асинхронная загрузка
Список при открытии загружает записи. Пока данные идут, нужен индикатор, при ошибке — сообщение. Это делает FutureBuilder: он перестраивает builder по мере того, как Future переходит из ожидания (ConnectionState.waiting) в завершение (done) с данными или ошибкой. Главное правило из документации API: Future нельзя создавать прямо в build — его получают заранее, например в initState. Иначе каждая перерисовка запускает загрузку заново.
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();
// Future создаём один раз. В build загрузка запускалась бы заново
// при каждой перерисовке.
_loading = context.read<NotesModel>().load();
}
Future<void> _openNewNote() async {
final saved = await context.push<bool>('/new');
if (!mounted) return; // экран могли закрыть, пока ждали результат
if (saved == true) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('Запись добавлена')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Записи')),
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('Ошибка загрузки: ${snapshot.error}'));
}
// watch: перерисовать список при каждом notifyListeners().
final notes = context.watch<NotesModel>().notes;
if (notes.isEmpty) {
return const Center(child: Text('Записей пока нет'));
}
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: 'Новая запись',
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; // параметр из адреса /notes/:id
@override
Widget build(BuildContext context) {
final note = context.watch<NotesModel>().byId(id);
if (note == null) {
return Scaffold(
appBar: AppBar(),
body: const Center(child: Text('Запись не найдена')),
);
}
return Scaffold(
appBar: AppBar(
title: Text(note.title),
actions: [
IconButton(
tooltip: 'Удалить',
icon: const Icon(Icons.delete_outline),
onPressed: () {
// read: в обработчике нужна модель, но не подписка на неё.
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(); // контроллеры освобождаем вместе с экраном
_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); // вернуть результат экрану, который открыл этот
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Новая запись')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
TextField(
controller: _title,
decoration: const InputDecoration(labelText: 'Заголовок'),
),
const SizedBox(height: 12),
TextField(
controller: _body,
decoration: const InputDecoration(labelText: 'Текст'),
maxLines: 4,
),
const SizedBox(height: 24),
FilledButton(onPressed: _save, child: const Text('Сохранить')),
],
),
);
}
}Обратите внимание на разделение: список записей — состояние приложения и живёт в NotesModel, а текст в полях формы — локальное состояние экрана в TextEditingController. Экран деталей получает только id и сам находит запись в модели, поэтому ему не важно, как его открыли — из списка или по адресу /notes/2. Ограничение учебной версии: если открыть адрес раньше, чем записи загрузятся, до окончания загрузки экран покажет «Запись не найдена». В рабочем приложении модель хранит признак загрузки, и экран деталей в это время показывает индикатор.
Проверка результата: widget-тесты
Тесты проходят по сценарию пользователя: увидеть индикатор загрузки, дождаться списка, открыть запись, вернуться, добавить новую и удалить старую. tester.pump(Duration) проматывает время для имитации загрузки, pumpAndSettle() ждёт окончания анимаций перехода. Пока крутится CircularProgressIndicator, pumpAndSettle() не вызывают: бесконечная анимация не даст ему завершиться.
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('состояние поднято в родителя', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: OrderScreen())),
);
for (var i = 0; i < 2; i++) {
await tester.tap(find.byTooltip('Больше'));
await tester.pump(); // перерисовка: кнопка получает новое value
}
expect(find.text('Итого чашек: 3'), findsOneWidget);
expect(find.text('3'), findsOneWidget); // ребёнок видит то же значение
});
testWidgets('Navigator: данные туда и обратно', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: TagButton())),
);
await tester.tap(find.text('Выбрать метку'));
await tester.pumpAndSettle(); // дождаться анимации перехода
await tester.tap(find.text('Дом'));
await tester.pumpAndSettle();
expect(find.text('Метка: Дом'), findsOneWidget);
});
testWidgets('список → детали → назад → новая запись', (tester) async {
await tester.pumpWidget(app());
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pump(const Duration(milliseconds: 300)); // «загрузка»
await tester.pump();
expect(find.text('Купить кофе'), findsOneWidget);
await tester.tap(find.text('Купить кофе'));
await tester.pumpAndSettle();
expect(find.text('Зерно для эспрессо, 1 кг'), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Новая запись'));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField).first, 'Оплатить интернет');
await tester.tap(find.text('Сохранить'));
await tester.pumpAndSettle();
expect(find.text('Оплатить интернет'), findsOneWidget);
expect(find.text('Запись добавлена'), findsOneWidget);
});
testWidgets('переход по адресу /notes/2 строит стек «список → детали»', (
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'); // как при открытии глубокой ссылки
await tester.pumpAndSettle();
expect(find.text('Записаться на ТО'), findsOneWidget);
expect(find.byType(BackButton), findsOneWidget);
await tester.pageBack();
await tester.pumpAndSettle();
expect(find.text('Купить кофе'), findsOneWidget);
});
testWidgets('удаление на экране деталей', (tester) async {
await tester.pumpWidget(app());
await tester.pump(const Duration(milliseconds: 300));
await tester.pump();
await tester.tap(find.text('Позвонить в сервис'));
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Удалить'));
await tester.pumpAndSettle();
expect(find.text('Позвонить в сервис'), findsNothing);
expect(find.text('Купить кофе'), findsOneWidget);
});
}$ flutter analyze
Analyzing notes_app...
No issues found! (ran in 8.7s)
$ flutter test
00:00 +0: состояние поднято в родителя
00:00 +1: Navigator: данные туда и обратно
00:00 +2: список → детали → назад → новая запись
00:01 +3: переход по адресу /notes/2 строит стек «список → детали»
00:01 +4: удаление на экране деталей
00:01 +5: All tests passed!flutter build web для этого проекта тоже проходит.- при запуске виден индикатор, через долю секунды — две записи
- нажатие на запись открывает детали со стрелкой «Назад»
- после «Сохранить» новая запись появляется в списке и показывается «Запись добавлена»
- после удаления запись исчезает из списка
Частые ошибки и упражнения
ProviderNotFoundException
- Причина
- Провайдер не стоит выше виджета, который его ищет, или в тесте забыли обернуть приложение
- Исправление
- Поставить
ChangeNotifierProviderнадMaterialApp, в тестах — тоже
Экран не обновляется после изменения модели
- Причина
- Нет
notifyListeners()или вместоwatchиспользованread - Исправление
- Вызывать
notifyListeners()в каждом методе-изменении, вbuild—watch
Загрузка повторяется бесконечно
- Причина
FutureдляFutureBuilderсоздаётся вbuild- Исправление
- Создать его в
initStateи хранить в поле
setState() called after dispose()
- Причина
setStateпослеawait, когда экран уже закрыт- Исправление
- Проверять
if (!mounted) return;
await context.push не возвращает результат
- Причина
- Переход сделан через
go, а он заменяет стек - Исправление
- Для экрана с результатом —
push, закрытие —pop(значение)
Дальше по траектории Flutter + Dart — рецепт формы с валидацией и widget-тестом, затем тестирование мобильных приложений и публикация в сторах.
Как проверено4 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
lib/models/note.dart, lib/notes_model.dart, lib/router.dart, lib/main.dart, lib/screens/*.dart, lib/basics/*.dart
flutter create + flutter pub add provider go_router (provider 6.1.5+1, go_router 18.0.2), flutter analyze — No issues found, flutter build web — ✓ Built build/web; Docker ghcr.io/cirruslabs/flutter:stable с SDK на теге 3.47.7 (Dart 3.13.5); RU и EN-версии кода проверены отдельно
- запущено, работает
test/widget_test.dart
flutter test: 5 тестов, All tests passed (RU и EN). Первая версия теста подъёма состояния (два tap без pump) давала 2 вместо 3 — этот результат описан в статье
- запущено, работает
Решения упражнений (счётчик в заголовке, подтверждение удаления, поиск)
копия проекта notes_app_ex с внесёнными решениями, flutter analyze без замечаний, 3 widget-теста прошли (RU и EN); фрагменты в статье взяты из этих файлов
- запущено, работает
Утверждение об открытии /notes/2 до окончания загрузки
отдельный временный тест: переход до загрузки, после 300 мс запись показывается
- не запускалось
Запуск на Android/iOS-устройстве, глубокие ссылки ОС, история браузера
в контейнере нет устройств и браузера; поведение сверено с docs.flutter.dev/ui/navigation и документацией go_router
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.