State Management Fundamentals

Lifting State Up

50 min Lesson 3 of 14

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]));
            },
          ),
        ),
      ],
    );
  }
}
Tip: Flutter provides type aliases for common callback signatures. 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');
  }
}
Warning: Prop drilling is not inherently wrong, but when you are passing data through 3 or more levels of widgets that do not use it, it becomes a maintenance burden. Every time you add a new piece of shared state, you must modify every intermediate widget. This is when you should consider 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],
              ),
            ),
          ],
        ),
      ),
    );
  }
}
Key Takeaway: Lifting state up is the foundational pattern for sharing state between widgets in Flutter. The parent owns the state, passes data down as constructor parameters, and receives change requests via callbacks. This ensures a single source of truth and unidirectional data flow. When prop drilling becomes excessive, consider using InheritedWidget or a state management package.