Lifting State Up
Why Lift State Up?
In Flutter, data flows downward through the widget tree via constructor parameters. When two or more sibling widgets need to share the same piece of state, neither widget can own that state alone. The solution is to lift the state up to their nearest common ancestor, which then passes the state down and provides callbacks for the children to request changes.
This pattern is fundamental to Flutter development and originates from React’s concept of “lifting state up.” It ensures a single source of truth for shared data and maintains a unidirectional data flow.
The Problem: Siblings Cannot Share State
// PROBLEM: WidgetA and WidgetB both need to show the same counter
// But they are siblings - neither can access the other's state
class ParentWidget extends StatelessWidget {
const ParentWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
WidgetA(), // Has its own counter
WidgetB(), // Needs the SAME counter value
// How do we keep them in sync?
],
);
}
}
Parent-Child Communication via Callbacks
The core mechanism for lifting state up involves two directions of communication:
- Parent to child: Pass data down as constructor parameters
- Child to parent: Pass callback functions down that the child invokes to request state changes
Basic Lifted State Pattern
// SOLUTION: Parent owns the state and passes it down
class CounterParent extends StatefulWidget {
const CounterParent({super.key});
@override
State<CounterParent> createState() => _CounterParentState();
}
class _CounterParentState extends State<CounterParent> {
int _counter = 0;
void _increment() {
setState(() {
_counter++;
});
}
void _decrement() {
setState(() {
_counter--;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Pass state DOWN as parameters
CounterDisplay(count: _counter),
// Pass callbacks DOWN for child to request changes
CounterControls(
onIncrement: _increment,
onDecrement: _decrement,
),
],
);
}
}
// This widget only DISPLAYS the counter - no state
class CounterDisplay extends StatelessWidget {
final int count;
const CounterDisplay({super.key, required this.count});
@override
Widget build(BuildContext context) {
return Text(
'\$count',
style: const TextStyle(fontSize: 48),
);
}
}
// This widget only provides CONTROLS - no state
class CounterControls extends StatelessWidget {
final VoidCallback onIncrement;
final VoidCallback onDecrement;
const CounterControls({
super.key,
required this.onIncrement,
required this.onDecrement,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: onDecrement,
icon: const Icon(Icons.remove_circle),
),
IconButton(
onPressed: onIncrement,
icon: const Icon(Icons.add_circle),
),
],
);
}
}
Passing State Down as Constructor Params
When you lift state up, every child that needs the data receives it through its constructor. This makes the data flow explicit and easy to trace. The child widgets become stateless — they are pure functions of their inputs.
Passing State Through Multiple Levels
class ShoppingApp extends StatefulWidget {
const ShoppingApp({super.key});
@override
State<ShoppingApp> createState() => _ShoppingAppState();
}
class _ShoppingAppState extends State<ShoppingApp> {
final List<Product> _cart = [];
void _addToCart(Product product) {
setState(() {
_cart.add(product);
});
}
void _removeFromCart(Product product) {
setState(() {
_cart.remove(product);
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Cart summary receives the cart data
CartSummary(
itemCount: _cart.length,
total: _cart.fold(0.0, (sum, p) => sum + p.price),
),
// Product list receives a callback to add items
ProductList(
onAddToCart: _addToCart,
cartItems: _cart,
),
],
);
}
}
class CartSummary extends StatelessWidget {
final int itemCount;
final double total;
const CartSummary({
super.key,
required this.itemCount,
required this.total,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: Colors.blue.shade50,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Items: \$itemCount'),
Text('Total: \\$\${total.toStringAsFixed(2)}'),
],
),
);
}
}
The Callback Pattern
Flutter uses callbacks extensively for child-to-parent communication. The most common patterns are onChanged, onSubmitted, onPressed, and custom callbacks using ValueChanged<T> or Function types.
Common Callback Patterns
// Custom search widget with callbacks
class SearchBar extends StatelessWidget {
final String query;
final ValueChanged<String> onQueryChanged;
final VoidCallback onClear;
final ValueChanged<String> onSubmitted;
const SearchBar({
super.key,
required this.query,
required this.onQueryChanged,
required this.onClear,
required this.onSubmitted,
});
@override
Widget build(BuildContext context) {
return TextField(
onChanged: onQueryChanged,
onSubmitted: onSubmitted,
decoration: InputDecoration(
hintText: 'Search...',
prefixIcon: const Icon(Icons.search),
suffixIcon: query.isNotEmpty
? IconButton(
onPressed: onClear,
icon: const Icon(Icons.clear),
)
: null,
),
);
}
}
// Parent manages the search state
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
String _query = '';
List<String> _allItems = ['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry'];
List<String> get _filteredItems {
if (_query.isEmpty) return _allItems;
return _allItems
.where((item) => item.toLowerCase().contains(_query.toLowerCase()))
.toList();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
SearchBar(
query: _query,
onQueryChanged: (value) {
setState(() {
_query = value;
});
},
onClear: () {
setState(() {
_query = '';
});
},
onSubmitted: (value) {
// Perform search action
},
),
Expanded(
child: ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
return ListTile(title: Text(_filteredItems[index]));
},
),
),
],
);
}
}
VoidCallback is a function with no arguments and no return value. ValueChanged<T> takes a single argument of type T and returns void. Use these standard types when possible for consistency.The Prop Drilling Problem
As your widget tree grows deeper, you may find yourself passing state and callbacks through many intermediate widgets that do not use them. This is known as prop drilling, and it is a sign that you might need a more sophisticated state management approach.
Prop Drilling Example
// The username needs to go from App all the way down to DeepChild
// Every widget in between must pass it along, even if they
// do not use it themselves
class App extends StatefulWidget {
const App({super.key});
@override
State<App> createState() => _AppState();
}
class _AppState extends State<App> {
String _username = 'Edrees';
@override
Widget build(BuildContext context) {
return PageWrapper(
username: _username, // Level 1: pass down
onUsernameChanged: (name) {
setState(() { _username = name; });
},
);
}
}
class PageWrapper extends StatelessWidget {
final String username; // Must declare
final ValueChanged<String> onUsernameChanged;
const PageWrapper({
super.key,
required this.username,
required this.onUsernameChanged,
});
@override
Widget build(BuildContext context) {
return ContentArea(
username: username, // Level 2: pass through
onUsernameChanged: onUsernameChanged,
);
}
}
class ContentArea extends StatelessWidget {
final String username; // Must declare again
final ValueChanged<String> onUsernameChanged;
const ContentArea({
super.key,
required this.username,
required this.onUsernameChanged,
});
@override
Widget build(BuildContext context) {
return ProfileSection(
username: username, // Level 3: pass through
onUsernameChanged: onUsernameChanged,
);
}
}
class ProfileSection extends StatelessWidget {
final String username; // Finally used here!
final ValueChanged<String> onUsernameChanged;
const ProfileSection({
super.key,
required this.username,
required this.onUsernameChanged,
});
@override
Widget build(BuildContext context) {
return Text('Hello, \$username');
}
}
InheritedWidget, Provider, or another state management solution.When Lifting State Is Enough
Lifting state up is the right choice when:
- Only 2-3 widgets need to share the state
- The widgets are close together in the tree (1-2 levels apart)
- The shared state is simple (a few variables)
- The callback chain is short and clear
You should consider more advanced solutions when:
- Many widgets across different subtrees need the same state
- You are passing data through 3+ levels of widgets that do not use it
- State changes are complex with many interdependencies
- You need to share state across routes or screens
Practical Example: Filter List
Filterable List with Lifted State
class FilterableList extends StatefulWidget {
const FilterableList({super.key});
@override
State<FilterableList> createState() => _FilterableListState();
}
class _FilterableListState extends State<FilterableList> {
String _selectedCategory = 'All';
String _searchQuery = '';
final List<Map<String, String>> _items = [
{'name': 'Flutter', 'category': 'Mobile'},
{'name': 'React', 'category': 'Web'},
{'name': 'SwiftUI', 'category': 'Mobile'},
{'name': 'Angular', 'category': 'Web'},
{'name': 'Kotlin', 'category': 'Mobile'},
{'name': 'Vue.js', 'category': 'Web'},
];
List<Map<String, String>> get _filteredItems {
return _items.where((item) {
final matchesCategory = _selectedCategory == 'All' ||
item['category'] == _selectedCategory;
final matchesSearch = _searchQuery.isEmpty ||
item['name']!.toLowerCase().contains(
_searchQuery.toLowerCase(),
);
return matchesCategory && matchesSearch;
}).toList();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Search bar - child communicates up via callback
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: const InputDecoration(
hintText: 'Search frameworks...',
prefixIcon: Icon(Icons.search),
),
),
),
// Category filter - child communicates up via callback
CategoryFilter(
categories: const ['All', 'Mobile', 'Web'],
selected: _selectedCategory,
onSelected: (category) {
setState(() {
_selectedCategory = category;
});
},
),
// Results count - receives state from parent
Padding(
padding: const EdgeInsets.all(8.0),
child: Text('Showing \${_filteredItems.length} results'),
),
// Item list - receives filtered data from parent
Expanded(
child: ListView.builder(
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
final item = _filteredItems[index];
return ListTile(
title: Text(item['name']!),
subtitle: Text(item['category']!),
);
},
),
),
],
);
}
}
class CategoryFilter extends StatelessWidget {
final List<String> categories;
final String selected;
final ValueChanged<String> onSelected;
const CategoryFilter({
super.key,
required this.categories,
required this.selected,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
children: categories.map((cat) {
return ChoiceChip(
label: Text(cat),
selected: selected == cat,
onSelected: (_) => onSelected(cat),
);
}).toList(),
);
}
}
Practical Example: Form with Live Preview
Form with Preview - Lifted State
class ProfileEditor extends StatefulWidget {
const ProfileEditor({super.key});
@override
State<ProfileEditor> createState() => _ProfileEditorState();
}
class _ProfileEditorState extends State<ProfileEditor> {
String _name = '';
String _bio = '';
Color _themeColor = Colors.blue;
@override
Widget build(BuildContext context) {
return Row(
children: [
// Left side: Form inputs
Expanded(
child: ProfileForm(
name: _name,
bio: _bio,
themeColor: _themeColor,
onNameChanged: (value) {
setState(() { _name = value; });
},
onBioChanged: (value) {
setState(() { _bio = value; });
},
onColorChanged: (color) {
setState(() { _themeColor = color; });
},
),
),
// Right side: Live preview
Expanded(
child: ProfilePreview(
name: _name,
bio: _bio,
themeColor: _themeColor,
),
),
],
);
}
}
class ProfileForm extends StatelessWidget {
final String name;
final String bio;
final Color themeColor;
final ValueChanged<String> onNameChanged;
final ValueChanged<String> onBioChanged;
final ValueChanged<Color> onColorChanged;
const ProfileForm({
super.key,
required this.name,
required this.bio,
required this.themeColor,
required this.onNameChanged,
required this.onBioChanged,
required this.onColorChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
onChanged: onNameChanged,
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 16),
TextField(
onChanged: onBioChanged,
maxLines: 3,
decoration: const InputDecoration(labelText: 'Bio'),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
children: [Colors.blue, Colors.red, Colors.green, Colors.purple]
.map((color) => GestureDetector(
onTap: () => onColorChanged(color),
child: CircleAvatar(
backgroundColor: color,
radius: 20,
child: themeColor == color
? const Icon(Icons.check, color: Colors.white)
: null,
),
))
.toList(),
),
],
),
);
}
}
class ProfilePreview extends StatelessWidget {
final String name;
final String bio;
final Color themeColor;
const ProfilePreview({
super.key,
required this.name,
required this.bio,
required this.themeColor,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: themeColor, width: 4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: themeColor,
radius: 30,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: const TextStyle(
color: Colors.white,
fontSize: 24,
),
),
),
const SizedBox(height: 12),
Text(
name.isNotEmpty ? name : 'Your Name',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: themeColor,
),
),
const SizedBox(height: 8),
Text(
bio.isNotEmpty ? bio : 'Your bio will appear here...',
style: TextStyle(
color: Colors.grey[600],
),
),
],
),
),
);
}
}
InheritedWidget or a state management package.