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.
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),
),
);
}
}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.
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
// 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.
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
setStatemutates state and schedules a rebuild of that subtree before the next frame.- Guard post-await
setStatecalls withif (!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.tryParsereturns null instead of throwing on bad input.- Use
keyboardType: TextInputType.numberWithOptions(decimal: true).