StatefulWidget & Lifecycle
StatefulWidget vs StatelessWidget
While a StatelessWidget is immutable and cannot change after being built, a StatefulWidget maintains mutable state that can change during the widget’s lifetime. When the state changes, the widget rebuilds its UI to reflect the new state.
A StatefulWidget consists of two classes:
- The widget class itself — immutable, defines the configuration
- The State class — mutable, holds the state and builds the UI
StatefulWidget Structure
import 'package:flutter/material.dart';
class CounterWidget extends StatefulWidget {
final int initialValue;
const CounterWidget({super.key, this.initialValue = 0});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
late int _count;
@override
void initState() {
super.initState();
_count = widget.initialValue;
}
@override
Widget build(BuildContext context) {
return Text('Count: \$_count');
}
}
_CounterWidgetState) making it private to the library. This is a Dart convention for implementation details that should not be accessed from outside.The createState() Method
The createState() method is the only required method in a StatefulWidget. It creates the mutable State object that will be associated with the widget. Flutter calls this method when it inserts the widget into the tree for the first time.
createState() Explained
class ToggleSwitch extends StatefulWidget {
final String label;
final ValueChanged<bool>? onChanged;
const ToggleSwitch({
super.key,
required this.label,
this.onChanged,
});
@override
State<ToggleSwitch> createState() => _ToggleSwitchState();
}
class _ToggleSwitchState extends State<ToggleSwitch> {
bool _isOn = false;
void _toggle() {
setState(() {
_isOn = !_isOn;
});
widget.onChanged?.call(_isOn);
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(widget.label),
const SizedBox(width: 8),
Switch(value: _isOn, onChanged: (_) => _toggle()),
],
);
}
}
Notice how the State class accesses the widget’s properties through the widget property. This gives the State object access to the immutable configuration defined in the StatefulWidget.
The State Class & Lifecycle Methods
The State class has several lifecycle methods that Flutter calls at specific moments. Understanding these is crucial for managing resources, subscriptions, and side effects.
Complete Lifecycle Overview
class LifecycleDemo extends StatefulWidget {
final String title;
const LifecycleDemo({super.key, required this.title});
@override
State<LifecycleDemo> createState() => _LifecycleDemoState();
}
class _LifecycleDemoState extends State<LifecycleDemo> {
// 1. Called once when State is created
@override
void initState() {
super.initState();
debugPrint('initState called');
// Initialize state, start animations, subscribe to streams
}
// 2. Called when dependencies change (e.g., InheritedWidget)
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('didChangeDependencies called');
// React to inherited widget changes
}
// 3. Called every time the UI needs to be rendered
@override
Widget build(BuildContext context) {
debugPrint('build called');
return Text(widget.title);
}
// 4. Called when parent rebuilds with new widget
@override
void didUpdateWidget(covariant LifecycleDemo oldWidget) {
super.didUpdateWidget(oldWidget);
debugPrint('didUpdateWidget called');
if (oldWidget.title != widget.title) {
// React to configuration changes
}
}
// 5. Called when widget is temporarily removed
@override
void deactivate() {
debugPrint('deactivate called');
super.deactivate();
}
// 6. Called when widget is permanently removed
@override
void dispose() {
debugPrint('dispose called');
// Clean up: cancel timers, close streams, dispose controllers
super.dispose();
}
}
initState() — Initialization
The initState() method is called exactly once when the State object is first created. This is where you should:
- Initialize state variables that depend on widget properties
- Set up animation controllers
- Subscribe to streams or listeners
- Start one-time asynchronous operations
initState() Example
class AnimatedGreeting extends StatefulWidget {
final String name;
const AnimatedGreeting({super.key, required this.name});
@override
State<AnimatedGreeting> createState() => _AnimatedGreetingState();
}
class _AnimatedGreetingState extends State<AnimatedGreeting>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0)
.animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeIn,
));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _fadeAnimation,
child: Text('Hello, \${widget.name}!',
style: const TextStyle(fontSize: 24),
),
);
}
}
setState() inside initState(). Also, the BuildContext is not fully available yet, so do not use Theme.of(context) or MediaQuery.of(context) here. Use didChangeDependencies() for that instead.didChangeDependencies()
This method is called immediately after initState() and whenever an InheritedWidget that this State depends on changes. This is the right place to access the BuildContext for the first time.
didChangeDependencies() Example
class ThemeAwareWidget extends StatefulWidget {
const ThemeAwareWidget({super.key});
@override
State<ThemeAwareWidget> createState() => _ThemeAwareWidgetState();
}
class _ThemeAwareWidgetState extends State<ThemeAwareWidget> {
late TextStyle _headerStyle;
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Safe to use context here
final theme = Theme.of(context);
_headerStyle = theme.textTheme.headlineMedium!.copyWith(
color: theme.colorScheme.primary,
);
}
@override
Widget build(BuildContext context) {
return Text('Themed Header', style: _headerStyle);
}
}
didUpdateWidget() — Reacting to Parent Changes
When the parent widget rebuilds and provides a new widget instance with the same runtimeType and key, Flutter calls didUpdateWidget() on the existing State object. This allows you to respond to configuration changes without losing state.
didUpdateWidget() Example
class CountdownTimer extends StatefulWidget {
final int seconds;
const CountdownTimer({super.key, required this.seconds});
@override
State<CountdownTimer> createState() => _CountdownTimerState();
}
class _CountdownTimerState extends State<CountdownTimer> {
late int _remaining;
Timer? _timer;
@override
void initState() {
super.initState();
_remaining = widget.seconds;
_startTimer();
}
@override
void didUpdateWidget(covariant CountdownTimer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.seconds != widget.seconds) {
_timer?.cancel();
_remaining = widget.seconds;
_startTimer();
}
}
void _startTimer() {
_timer = Timer.periodic(
const Duration(seconds: 1),
(timer) {
if (_remaining > 0) {
setState(() => _remaining--);
} else {
timer.cancel();
}
},
);
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(
'\$_remaining s',
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: _remaining < 10 ? Colors.red : Colors.black,
),
);
}
}
setState() — Triggering Rebuilds
The setState() method is how you tell Flutter that the state has changed and the UI needs to be rebuilt. Always modify state variables inside the setState() callback.
setState() Usage Patterns
class ShoppingCart extends StatefulWidget {
const ShoppingCart({super.key});
@override
State<ShoppingCart> createState() => _ShoppingCartState();
}
class _ShoppingCartState extends State<ShoppingCart> {
final List<String> _items = [];
int _totalCount = 0;
void _addItem(String item) {
setState(() {
_items.add(item);
_totalCount = _items.length;
});
}
void _removeItem(int index) {
setState(() {
_items.removeAt(index);
_totalCount = _items.length;
});
}
void _clearCart() {
setState(() {
_items.clear();
_totalCount = 0;
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Cart: \$_totalCount items',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
..._items.asMap().entries.map((entry) => ListTile(
title: Text(entry.value),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _removeItem(entry.key),
),
)),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () => _addItem('Item \${_totalCount + 1}'),
child: const Text('Add Item'),
),
TextButton(
onPressed: _items.isEmpty ? null : _clearCart,
child: const Text('Clear All'),
),
],
),
],
);
}
}
setState() minimal — only modify state variables. Do not perform heavy computations or async operations inside the callback. Modify the variables, and let the build() method handle the rendering logic.The mounted Property
The mounted property indicates whether the State object is currently in the widget tree. This is critical for asynchronous operations — you must check mounted before calling setState() after an await.
Checking mounted Before setState
class DataLoader extends StatefulWidget {
final String url;
const DataLoader({super.key, required this.url});
@override
State<DataLoader> createState() => _DataLoaderState();
}
class _DataLoaderState extends State<DataLoader> {
String? _data;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
try {
// Simulate network request
await Future.delayed(const Duration(seconds: 2));
final result = 'Data loaded from \${widget.url}';
// CRITICAL: Check mounted before setState
if (!mounted) return;
setState(() {
_data = result;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(child: Text('Error: \$_error'));
}
return Center(child: Text(_data ?? 'No data'));
}
}
setState() on an unmounted State object throws an error. Always check if (!mounted) return; after any asynchronous gap (after await). This prevents crashes when the user navigates away before the async operation completes.deactivate() and dispose()
deactivate() is called when the State is temporarily removed from the tree (e.g., when moving a widget using a GlobalKey). dispose() is called when the State is permanently removed and will never build again.
Proper Resource Cleanup
class StreamListenerWidget extends StatefulWidget {
const StreamListenerWidget({super.key});
@override
State<StreamListenerWidget> createState() => _StreamListenerWidgetState();
}
class _StreamListenerWidgetState extends State<StreamListenerWidget> {
late final ScrollController _scrollController;
late final TextEditingController _textController;
StreamSubscription? _subscription;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_textController = TextEditingController();
// Subscribe to a stream
_subscription = Stream.periodic(
const Duration(seconds: 1),
(i) => i,
).listen((value) {
if (mounted) {
setState(() {
// Update UI with stream value
});
}
});
}
@override
void deactivate() {
// Called when temporarily removed from tree
debugPrint('Widget deactivated');
super.deactivate();
}
@override
void dispose() {
// Clean up ALL resources
_scrollController.dispose();
_textController.dispose();
_subscription?.cancel();
debugPrint('Widget disposed - resources cleaned up');
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Text('Stream Listener Active');
}
}
Practical Example: Interactive Counter
Let’s build a feature-rich counter that demonstrates StatefulWidget patterns:
Interactive Counter Widget
class InteractiveCounter extends StatefulWidget {
final int min;
final int max;
final int step;
final ValueChanged<int>? onChanged;
const InteractiveCounter({
super.key,
this.min = 0,
this.max = 100,
this.step = 1,
this.onChanged,
});
@override
State<InteractiveCounter> createState() => _InteractiveCounterState();
}
class _InteractiveCounterState extends State<InteractiveCounter> {
late int _value;
@override
void initState() {
super.initState();
_value = widget.min;
}
void _increment() {
if (_value + widget.step <= widget.max) {
setState(() => _value += widget.step);
widget.onChanged?.call(_value);
}
}
void _decrement() {
if (_value - widget.step >= widget.min) {
setState(() => _value -= widget.step);
widget.onChanged?.call(_value);
}
}
void _reset() {
setState(() => _value = widget.min);
widget.onChanged?.call(_value);
}
@override
Widget build(BuildContext context) {
final progress = (_value - widget.min) / (widget.max - widget.min);
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'\$_value',
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
color: Color.lerp(Colors.blue, Colors.red, progress),
),
),
const SizedBox(height: 8),
LinearProgressIndicator(value: progress),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton.filled(
onPressed: _value > widget.min ? _decrement : null,
icon: const Icon(Icons.remove),
),
const SizedBox(width: 16),
IconButton.outlined(
onPressed: _reset,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 16),
IconButton.filled(
onPressed: _value < widget.max ? _increment : null,
icon: const Icon(Icons.add),
),
],
),
],
),
),
);
}
}
Practical Example: Form with State
Forms are a classic use case for StatefulWidget because they need to track input values and validation state:
Simple Registration Form
class RegistrationForm extends StatefulWidget {
const RegistrationForm({super.key});
@override
State<RegistrationForm> createState() => _RegistrationFormState();
}
class _RegistrationFormState extends State<RegistrationForm> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
bool _agreedToTerms = false;
bool _isSubmitting = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate() || !_agreedToTerms) return;
setState(() => _isSubmitting = true);
// Simulate API call
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
setState(() => _isSubmitting = false);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Registration successful!')),
);
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Full Name',
prefixIcon: Icon(Icons.person),
),
validator: (value) =>
value?.isEmpty ?? true ? 'Name is required' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email),
),
validator: (value) {
if (value?.isEmpty ?? true) return 'Email is required';
if (!value!.contains('@')) return 'Invalid email';
return null;
},
),
const SizedBox(height: 16),
CheckboxListTile(
value: _agreedToTerms,
onChanged: (v) => setState(() => _agreedToTerms = v ?? false),
title: const Text('I agree to the terms'),
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
height: 20, width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Register'),
),
],
),
),
);
}
}
Summary
In this lesson, you learned:
- StatefulWidget consists of two classes: the widget (immutable) and the State (mutable)
- createState() creates the mutable State object associated with the widget
- initState() runs once for initialization; dispose() runs once for cleanup
- didChangeDependencies() responds to InheritedWidget changes and is safe to use BuildContext
- didUpdateWidget() fires when the parent provides a new widget configuration
- setState() triggers a rebuild — always check mounted after async gaps
- Always dispose controllers, subscriptions, and timers to prevent memory leaks