Kodokon kodokon.com

Sharing state: lifting state up and callbacks

Keep state in the common ancestor and turn your child widgets into controlled widgets that report changes back through callbacks.

8 min · 3 questions

Open this lesson in Kodokon

A classic situation: a quantity selector and a total displayed elsewhere need to share the same data. The golden rule: state lives in the nearest common ancestor of the widgets that depend on it. The parent owns the data, passes it down as a parameter, and receives changes through a callback that flows back up. This is "lifting state up".

DART
import 'package:flutter/material.dart';

class CartScreen extends StatefulWidget {
  const CartScreen({super.key});

  @override
  State<CartScreen> createState() => _CartScreenState();
}

class _CartScreenState extends State<CartScreen> {
  int _quantity = 1;

  void _updateQuantity(int value) {
    setState(() => _quantity = value);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Cart')),
      body: Column(
        children: [
          QuantitySelector(
            quantity: _quantity,
            onChanged: _updateQuantity,
          ),
          Text('Total: ${_quantity * 20} €'),
        ],
      ),
    );
  }
}
The parent owns the state and hands out the value and callback.

The child becomes a controlled widget: it's a StatelessWidget, stores nothing, displays the value it receives, and signals each intent through onChanged. ValueChanged<int> is simply an alias for void Function(int). Notice the minus button disabled at quantity 1: passing null to onPressed is enough, and this logic follows directly from the value received.

DART
import 'package:flutter/material.dart';

class QuantitySelector extends StatelessWidget {
  const QuantitySelector({
    super.key,
    required this.quantity,
    required this.onChanged,
  });

  final int quantity;
  final ValueChanged<int> onChanged;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        IconButton(
          icon: const Icon(Icons.remove),
          onPressed: quantity > 1
              ? () => onChanged(quantity - 1)
              : null,
        ),
        Text('$quantity'),
        IconButton(
          icon: const Icon(Icons.add),
          onPressed: () => onChanged(quantity + 1),
        ),
      ],
    );
  }
}
The child displays the value it receives and reports intents back up.

You use this contract all the time without realizing it: Checkbox, Slider, Switch are all controlled widgets (value + onChanged). When the tree deepens and threading callbacks through five levels becomes tedious, it will be time to discover state management solutions - that's the subject of the next module.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Where should you place state shared by two sibling widgets?
    • In each of the two widgets
    • In the nearest common ancestor
    • In a global variable
    • In the MaterialApp
  2. How does a child widget request a change to the state?
    • It calls setState directly on its parent
    • It modifies the property it received as a parameter
    • It invokes the callback provided by the parent
  3. What is a controlled widget?
    • A widget that receives its value and reports changes via a callback, with no state of its own
    • A widget that must use setState
    • A widget covered by unit tests
    • A widget declared const