Stateful Widgets and User Input
Prerequisites: Flutter fundamentals
A StatelessWidget can't change. When a widget has data that changes —
a counter, a form field, a toggle — use a StatefulWidget.
A counter
import 'package:flutter/material.dart';
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
int _count = 0;
void _increment() {
setState(() {
_count++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter')),
body: Center(
child: Text('Count: $_count', style: Theme.of(context).textTheme.headlineLarge),
),
floatingActionButton: FloatingActionButton(
onPressed: _increment,
tooltip: 'Add one',
child: const Icon(Icons.add),
),
);
}
}
- A stateful widget comes in two parts: the widget (configuration) and a
Stateobject that holds the changing data and survives rebuilds. setStatetells Flutter the data changed, so it callsbuildagain. Changing_countwithoutsetStateupdates the variable but not the screen.onPressed: _incrementpasses a function (a tear-off) to the button.
Lifecycle
State has lifecycle methods:
initState()— runs once when the widget is inserted: start listening to streams, create controllers.dispose()— runs when it's removed: cancel subscriptions, dispose controllers. Forgetting this causes memory leaks.
Text input
class NameForm extends StatefulWidget {
const NameForm({super.key});
@override
State<NameForm> createState() => _NameFormState();
}
class _NameFormState extends State<NameForm> {
final _controller = TextEditingController();
String _greeting = '';
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
TextField(
controller: _controller,
decoration: const InputDecoration(labelText: 'Your name'),
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => setState(() => _greeting = 'Hello, ${_controller.text}!'),
child: const Text('Greet'),
),
Text(_greeting),
],
),
);
}
}
Lists
ListView.builder builds rows lazily, only for the items on screen, so
it handles long lists efficiently:
ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => ListTile(
title: Text(items[index]),
onTap: () => print('Tapped ${items[index]}'),
),
)
Keep local state local
setState is perfect for state that belongs to one widget — a text
field, an expanded panel, an animation. When several screens share
data, move it out of widgets into a state management solution — the
next lesson.