Skip to content
FlutterLearn

Beginner · Lesson 5 · 11 min read

State, setState and User Input

Handle taps, text input and switches, and understand exactly what setState does — and where state should live.

Updated July 22, 2026

What you will learn

  • Use setState correctly and know what it actually triggers
  • Handle text input with TextEditingController
  • Decide where a piece of state belongs (lifting state up)
  • Avoid the most common state-related memory leaks

State is any data that can change while your app runs and that affects what is on screen. Flutter's rule is simple: when state changes, you tell the framework, and the framework rebuilds the affected widgets.

What setState actually does

setState does two things: it runs the closure you give it, then marks this element as dirty so Flutter rebuilds it before the next frame. It does not rebuild immediately, and it does not rebuild the whole app — only the subtree from this State down.

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

  @override
  State<CounterScreen> createState() => _CounterScreenState();
}

class _CounterScreenState extends State<CounterScreen> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++; // mutate inside the closure
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter')),
      body: Center(
        child: Text('$_count', style: Theme.of(context).textTheme.displayLarge),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _increment,
        child: const Icon(Icons.add),
      ),
    );
  }
}
Dart
Future<void> _loadUser() async {
  setState(() => _loading = true);

  final user = await api.fetchUser();

  // The user may have navigated away while we were awaiting
  if (!mounted) return;

  setState(() {
    _user = user;
    _loading = false;
  });
}

Text input

TextField is uncontrolled by default — it manages its own text. To read or set that text from code, attach a TextEditingController. Controllers must be disposed.

Dart
class SearchBox extends StatefulWidget {
  const SearchBox({super.key, required this.onSubmit});

  final ValueChanged<String> onSubmit;

  @override
  State<SearchBox> createState() => _SearchBoxState();
}

class _SearchBoxState extends State<SearchBox> {
  final _controller = TextEditingController();
  final _focusNode = FocusNode();
  bool _hasText = false;

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      final hasText = _controller.text.isNotEmpty;
      if (hasText != _hasText) {
        setState(() => _hasText = hasText);
      }
    });
  }

  @override
  void dispose() {
    _controller.dispose();  // required — otherwise you leak
    _focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      focusNode: _focusNode,
      textInputAction: TextInputAction.search,
      onSubmitted: widget.onSubmit,
      decoration: InputDecoration(
        hintText: 'Search lessons',
        prefixIcon: const Icon(Icons.search),
        suffixIcon: _hasText
            ? IconButton(
                icon: const Icon(Icons.clear),
                onPressed: () => _controller.clear(),
              )
            : null,
      ),
    );
  }
}

Common input widgets

Dart
// Toggle
Switch(value: _notifications, onChanged: (v) => setState(() => _notifications = v))

// Checkbox with a label and correct tap target
CheckboxListTile(
  value: _agreed,
  onChanged: (v) => setState(() => _agreed = v ?? false),
  title: const Text('I agree to the terms'),
  controlAffinity: ListTileControlAffinity.leading,
)

// Slider
Slider(
  value: _volume,
  min: 0,
  max: 100,
  divisions: 10,
  label: '${_volume.round()}',
  onChanged: (v) => setState(() => _volume = v),
)

// Dropdown
DropdownButton<String>(
  value: _level,
  items: const [
    DropdownMenuItem(value: 'beginner', child: Text('Beginner')),
    DropdownMenuItem(value: 'advanced', child: Text('Advanced')),
  ],
  onChanged: (v) => setState(() => _level = v!),
)

Notice the pattern: every one of these takes a current value and an onChanged callback. They do not hold state themselves — you hold it, and you hand it back down. That is what makes them predictable.

Where should state live?

Put state in the lowest common ancestor of every widget that needs it. If only one widget cares, keep it local. If a sibling needs it too, lift it to the shared parent and pass values down and callbacks up.

Dart
class CartScreen extends StatefulWidget {
  const CartScreen({super.key});
  @override
  State<CartScreen> createState() => _CartScreenState();
}

class _CartScreenState extends State<CartScreen> {
  final List<String> _items = [];

  void _add(String item) => setState(() => _items.add(item));
  void _remove(String item) => setState(() => _items.remove(item));

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Child receives a callback — it never owns the list
        ProductPicker(onAdd: _add),
        Expanded(
          child: CartList(items: _items, onRemove: _remove),
        ),
        CartTotal(count: _items.length),
      ],
    );
  }
}

This works well until the tree gets deep and you find yourself passing a callback through five widgets that do not care about it — "prop drilling". That is the signal to reach for a real state management solution, which is the first lesson of the intermediate module.

Key takeaways

  • setState mutates state and schedules a rebuild of that subtree before the next frame.
  • Guard post-await setState calls with if (!mounted) return;.
  • Always dispose controllers and focus nodes in dispose().
  • Keep state as low as possible; lift it to the lowest common ancestor when siblings need it.

Practice

A tip calculator

Build a screen with a bill amount TextField, a slider for tip percentage, and a live-updating total. Show the total per person with a stepper for the number of people. Make sure the controller is disposed and the app does not crash on empty or invalid input.

Show hints
  • double.tryParse returns null instead of throwing on bad input.
  • Use keyboardType: TextInputType.numberWithOptions(decimal: true).