Why Shared State Matters
In real apps, multiple widgets often need to read and update the same piece of data. Examples include: a “dark mode” setting that affects the whole app, a favorites list used on a list screen and a detail screen, or a logged-in user profile shown in several places.
If each screen keeps its own copy of the data, you quickly get problems: one screen updates, another screen doesn’t reflect the change; you end up passing values through many constructors; and it becomes hard to know where the “real” state lives.
Shared state solves this by storing the data in one place and letting any widget below it in the widget tree read it and react to changes.
Common beginner pain: “prop drilling”
When you pass state down through many widget constructors just so a deep child can use it, you create tight coupling and lots of boilerplate. This is sometimes called prop drilling. Shared state patterns reduce this by letting widgets access state from the context.
Two Simple Approaches: InheritedWidget and Provider-Style
Flutter’s core mechanism for sharing data down the tree is InheritedWidget. Many popular patterns (including Provider) build on this idea: put a state holder high in the tree, then read it anywhere below using BuildContext.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
- InheritedWidget: built-in, no dependencies, great for learning the underlying concept.
- Provider-style: typically uses an external package, but conceptually it’s “an InheritedWidget + a notifier + helper methods.” You can implement a lightweight version yourself to understand how it works.
In this chapter, you’ll implement a small “Provider-style” pattern using InheritedNotifier (a convenient variant of InheritedWidget that listens to a Listenable such as ChangeNotifier).
When to Lift State Up vs Introduce Shared State
Lift state up when:
- The state is only needed by a parent and its direct children (a small subtree).
- The state changes are local to one screen or one feature area.
- You can pass callbacks and values down 1–2 levels without pain.
Introduce shared state when:
- Multiple screens need the same state (e.g., favorites across a list screen and a details screen).
- You’re passing the same data through many widget constructors just to reach deep widgets.
- You need a single source of truth for app-wide settings (theme mode, locale, authentication).
Rule of thumb
Start by lifting state up. If you notice repeated parameter passing, duplicated state, or cross-screen synchronization issues, move that state into a shared container placed higher in the widget tree.
Implementation Walkthrough: Shared Favorites Across Multiple Screens
You’ll build a minimal shared state system for a favorites list. The goal: a user can favorite items on a list screen, and the favorites screen updates automatically. A details screen can also toggle favorite state.
Step 1: Create the app state (ChangeNotifier)
This class holds the shared data and notifies listeners when it changes.
import 'package:flutter/foundation.dart';
class AppState extends ChangeNotifier {
final Set<int> _favoriteIds = {};
bool isFavorite(int id) => _favoriteIds.contains(id);
List<int> get favorites => _favoriteIds.toList()..sort();
void toggleFavorite(int id) {
if (_favoriteIds.contains(id)) {
_favoriteIds.remove(id);
} else {
_favoriteIds.add(id);
}
notifyListeners();
}
}notifyListeners() is the key: any widget that is “listening” will rebuild when favorites change.
Step 2: Create a lightweight provider using InheritedNotifier
This widget exposes AppState to the subtree via BuildContext. Because it extends InheritedNotifier, it automatically rebuilds dependents when the notifier changes.
import 'package:flutter/widgets.dart';
import 'app_state.dart';
class AppStateScope extends InheritedNotifier<AppState> {
const AppStateScope({
super.key,
required AppState notifier,
required Widget child,
}) : super(notifier: notifier, child: child);
static AppState of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppStateScope>();
assert(scope != null, 'No AppStateScope found in context');
return scope!.notifier!;
}
}Important concept: dependOnInheritedWidgetOfExactType registers this widget as a dependent. When the notifier calls notifyListeners(), Flutter rebuilds dependents.
Step 3: Place the scope high enough in the widget tree
Wrap your app (or at least the part that needs shared state) with AppStateScope.
import 'package:flutter/material.dart';
import 'app_state.dart';
import 'app_state_scope.dart';
import 'screens/items_screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({super.key});
final AppState _state = AppState();
@override
Widget build(BuildContext context) {
return AppStateScope(
notifier: _state,
child: MaterialApp(
home: const ItemsScreen(),
),
);
}
}Placing it above MaterialApp is common when many screens need it. If only a small section needs it, place it closer to that section to limit rebuild scope.
Step 4: Build an items list screen that reads and updates shared state
This screen shows items and lets the user toggle favorites. It reads state via AppStateScope.of(context).
import 'package:flutter/material.dart';
import '../app_state_scope.dart';
import 'favorites_screen.dart';
import 'item_details_screen.dart';
class ItemsScreen extends StatelessWidget {
const ItemsScreen({super.key});
@override
Widget build(BuildContext context) {
final appState = AppStateScope.of(context);
final items = List.generate(20, (i) => i + 1);
return Scaffold(
appBar: AppBar(
title: const Text('Items'),
actions: [
IconButton(
icon: const Icon(Icons.favorite),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const FavoritesScreen()),
);
},
),
],
),
body: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final id = items[index];
final isFav = appState.isFavorite(id);
return ListTile(
title: Text('Item $id'),
trailing: IconButton(
icon: Icon(isFav ? Icons.favorite : Icons.favorite_border),
onPressed: () => appState.toggleFavorite(id),
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ItemDetailsScreen(itemId: id),
),
);
},
);
},
),
);
}
}What to notice: there is no local favorites state here. The list screen is simply a view over shared state.
Step 5: Create a favorites screen that stays in sync automatically
This screen reads the same shared state. When favorites change anywhere, this screen rebuilds because it depends on AppStateScope.
import 'package:flutter/material.dart';
import '../app_state_scope.dart';
import 'item_details_screen.dart';
class FavoritesScreen extends StatelessWidget {
const FavoritesScreen({super.key});
@override
Widget build(BuildContext context) {
final appState = AppStateScope.of(context);
final favorites = appState.favorites;
return Scaffold(
appBar: AppBar(title: const Text('Favorites')),
body: favorites.isEmpty
? const Center(child: Text('No favorites yet'))
: ListView.builder(
itemCount: favorites.length,
itemBuilder: (context, index) {
final id = favorites[index];
return ListTile(
title: Text('Item $id'),
trailing: IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: () => appState.toggleFavorite(id),
),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ItemDetailsScreen(itemId: id),
),
);
},
);
},
),
);
}
}Step 6: Add a details screen that also uses the shared state
This demonstrates cross-screen consistency: toggling favorite here updates both the list and favorites screens.
import 'package:flutter/material.dart';
import '../app_state_scope.dart';
class ItemDetailsScreen extends StatelessWidget {
final int itemId;
const ItemDetailsScreen({super.key, required this.itemId});
@override
Widget build(BuildContext context) {
final appState = AppStateScope.of(context);
final isFav = appState.isFavorite(itemId);
return Scaffold(
appBar: AppBar(title: Text('Item $itemId')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Details for item $itemId'),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => appState.toggleFavorite(itemId),
icon: Icon(isFav ? Icons.favorite : Icons.favorite_border),
label: Text(isFav ? 'Unfavorite' : 'Favorite'),
),
],
),
),
);
}
}Understanding Rebuilds and Context Usage
Why does the UI update automatically?
Because AppStateScope.of(context) uses dependOnInheritedWidgetOfExactType, the calling widget becomes a dependent. When AppState calls notifyListeners(), InheritedNotifier marks dependents to rebuild.
Common beginner mistake: reading state in the wrong place
- Don’t call
AppStateScope.of(context)in a place where the context is above the scope (for example, in a widget that is created before the scope is inserted). - Do ensure the widget is below
AppStateScopein the widget tree.
Reducing unnecessary rebuilds
In the examples above, the whole screen rebuilds when favorites change. That’s fine for small apps. If you want to rebuild only a small part (like a single list tile), you can move the AppStateScope.of(context) call deeper so fewer widgets depend on it.
// Example idea: read appState inside itemBuilder so only tiles depend on it
itemBuilder: (context, index) {
final appState = AppStateScope.of(context);
...
}Optional Variation: Sharing a Settings Toggle
The same pattern works for settings like “show only favorites” or “enable notifications.” You’d store a boolean in AppState, expose a getter, and call notifyListeners() when toggled. Any screen that reads the value will rebuild and reflect the new setting.
class AppState extends ChangeNotifier {
bool _showOnlyFavorites = false;
bool get showOnlyFavorites => _showOnlyFavorites;
void toggleShowOnlyFavorites() {
_showOnlyFavorites = !_showOnlyFavorites;
notifyListeners();
}
}This is the same shared-state idea: one source of truth, multiple consumers, automatic UI updates.