Stateless vs Stateful Widgets
Master widget states, lifecycle methods, and when to use each type.
StatelessWidget
A StatelessWidget is immutable — once created, it cannot change its internal state. Use it for static content that depends only on its constructor arguments:
greeting_card.dart
class GreetingCard extends StatelessWidget {
final String userName;
final String message;
const GreetingCard({
super.key,
required this.userName,
this.message = 'Welcome back!',
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text('Hello, $userName!',
style: Theme.of(context).textTheme.headlineSmall),
Text(message),
],
),
),
);
}
}
StatefulWidget
A StatefulWidget is mutable. It maintains a State object that persists across rebuilds:
toggle_switch.dart
class ToggleSwitch extends StatefulWidget {
final String label;
final bool initialValue;
final ValueChanged<bool>? onChanged;
const ToggleSwitch({
super.key,
required this.label,
this.initialValue = false,
this.onChanged,
});
@override
State<ToggleSwitch> createState() => _ToggleSwitchState();
}
class _ToggleSwitchState extends State<ToggleSwitch> {
late bool _isOn;
@override
void initState() {
super.initState();
_isOn = widget.initialValue;
}
void _toggle() {
setState(() {
_isOn = !_isOn;
});
widget.onChanged?.call(_isOn);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(widget.label),
Switch(value: _isOn, onChanged: (_) => _toggle()),
],
);
}
}
Complete Lifecycle Diagram
Understanding the widget lifecycle is critical for writing efficient Flutter apps:
| Method | When Called | Common Use |
|---|---|---|
createState() | First time widget appears | Create the State object |
initState() | Once, when State is created | Subscriptions, controllers, API calls |
didChangeDependencies() | After initState, or when InheritedWidget changes | Read Theme, MediaQuery, Locale |
build() | After initState, after setState | Return widget tree |
didUpdateWidget() | Parent rebuilds with new config | Compare old vs new props |
dispose() | Before removal from tree | Cancel subscriptions, close controllers |
⚠️ Common Mistake: initState() and Build Context
Never use
BuildContext in initState() to look up inherited widgets (like Theme.of(context)). The context isn't fully initialized yet. Use didChangeDependencies() instead — it's called right after initState() and the context is ready.
The Build Method Contract
The build() method has strict rules that every Flutter developer must follow:
- Must be pure: It should only depend on the widget's state and incoming parameters.
- Must be fast: It's called frequently (every setState, parent rebuild, etc.).
- Must return a widget: Even empty containers return a widget.
- Must not have side effects: No network calls, no timers, no mutations.
Stateful Widget Lifecycle
| Method | When Called | Use For |
|---|---|---|
createState() | First time widget inserted in tree | Create State object |
initState() | State created (once) | Initialize subscriptions, controllers |
didChangeDependencies() | InheritedWidget changes | Read Theme, MediaQuery, etc. |
build() | After initState, setState, deps change | Return widget tree |
didUpdateWidget() | Parent rebuilds with new config | React to parent changes |
dispose() | Removed from tree permanently | Cleanup: close streams, dispose controllers |
setState()
setState() notifies Flutter that internal state changed, triggering a rebuild:
counter.dart
class CounterWidget extends StatefulWidget {
const CounterWidget({super.key});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _count = 0;
void _increment() => setState(() => _count++);
void _decrement() => setState(() {
if (_count > 0) _count--;
});
void _reset() => setState(() => _count = 0);
@override
Widget build(BuildContext context) {
return Column(mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('$_count', style: const TextStyle(fontSize: 64)),
Row(mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(onPressed: _decrement, child: const Text('−')),
SizedBox(width: 16),
ElevatedButton(onPressed: _reset, child: const Text('Reset')),
SizedBox(width: 16),
ElevatedButton(onPressed: _increment, child: const Text('+')),
],
),
],
);
}
}
Real-World Example: Dynamic Toggle Switch
settings_screen.dart
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
bool _notifications = true;
bool _darkMode = false;
bool _biometrics = false;
@override
Widget build(BuildContext context) {
return ListView(children: [
SwitchListTile(
title: const Text('Notifications'),
value: _notifications,
onChanged: (v) => setState(() => _notifications = v),
),
SwitchListTile(
title: const Text('Dark Mode'),
value: _darkMode,
onChanged: (v) => setState(() => _darkMode = v),
),
SwitchListTile(
title: const Text('Biometric Login'),
value: _biometrics,
onChanged: (v) => setState(() => _biometrics = v),
),
]);
}
}
Summary
StatelessWidget= immutable, no internal stateStatefulWidget= mutable, maintains State objectinitState()→build()→dispose()is the core lifecyclesetState()triggers rebuild — keep it minimal
🚀 Next Step Continue to Building Complex UIs to master layout widgets.