Flutter: StatefulWidget and Interaction
State is a Widget's memory — without State, an interface is like an amnesiac who doesn't recognize you each time.
📋 Prerequisites: You need to have completed the following first
- Lesson 3: Widget Fundamentals
1. What You Will Learn
- Complete StatefulWidget lifecycle: createState → initState → build → dispose
setStaterebuild mechanism and minimal rebuild scope- Event handling: GestureDetector, InkWell, onPressed callbacks
- Input interaction: TextField, TextEditingController text listening
- ShopApp shopping cart quantity selector implementation
2. A Real Story About an Interaction Dilemma
(1) The Pain: Clicks Not Responding
Bob encountered a strange bug on the ShopApp cart page: users clicked the "-" button, the quantity went from 1 to 0, but the UI still displayed 1. He directly modified the variable _count = _count - 1 without calling setState — the Widget didn't know the state changed, so naturally it wouldn't rebuild the UI.
(2) The setState Solution
StatefulWidget's core principle: changing state doesn't equal changing UI. You must notify the framework via setState that "I've changed, please rebuild."
import 'package:flutter/material.dart';
// WRONG: Direct mutation, UI not updated
void _decrement() {
_count = _count - 1; // State changed, but UI doesn't know
}
// RIGHT: setState triggers rebuild
void _decrement() {
setState(() {
_count = _count - 1; // Framework rebuilds this widget
});
}
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
(3) The Result: UI and State Always in Sync
After adding setState, the cart quantity updated in real time and the total price recalculated automatically. From then on, Bob remembered: when you change data, you must call setState.
3. StatefulWidget Lifecycle
stateDiagram-v2
[*] --> createState
createState --> initState
initState --> didChangeDependencies
didChangeDependencies --> build
build --> didUpdateWidget: setState / parent rebuild
didUpdateWidget --> build
build --> deactivate: removed from tree
deactivate --> dispose
dispose --> [*]
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
(1) Lifecycle Callbacks
| Callback | Trigger Timing | Purpose |
|---|---|---|
createState |
Framework creates State | Initialize State object |
initState |
State inserted into tree | One-time initialization (subscriptions, controllers) |
didChangeDependencies |
Dependent InheritedWidget changes | Read theme, locale, etc. |
build |
Every time UI needs rendering | Build Widget tree |
didUpdateWidget |
Parent Widget rebuilds | Compare old/new Widget and respond |
setState |
Developer calls explicitly | Mark dirty, trigger rebuild |
deactivate |
State removed from tree | Temporary removal (can be reinserted) |
dispose |
State permanently destroyed | Release resources, cancel subscriptions |
▶ Example
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
: Lifecycle tracking
import 'package:flutter/material.dart';
class LifecycleTracker extends StatefulWidget {
const LifecycleTracker({super.key});
@override
State<LifecycleTracker> createState() => _LifecycleTrackerState();
}
class _LifecycleTrackerState extends State<LifecycleTracker> {
int _buildCount = 0;
@override
void initState() {
super.initState();
debugPrint('initState called');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('didChangeDependencies called');
}
@override
void didUpdateWidget(covariant LifecycleTracker oldWidget) {
super.didUpdateWidget(oldWidget);
debugPrint('didUpdateWidget called');
}
@override
Widget build(BuildContext context) {
_buildCount++;
debugPrint('build #$_buildCount');
return Text('Build count: $_buildCount');
}
@override
void dispose() {
debugPrint('dispose called');
super.dispose();
}
}
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
4. setState Mechanism In Depth
(1) setState Workflow
| Step | Action |
|---|---|
| 1 | Call setState(fn) |
| 2 | Execute fn, update state variables |
| 3 | Mark Element as dirty |
| 4 | Next frame, Framework calls build |
| 5 | Diff old/new Widget trees, update RenderObject |
▶ Example
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
: Cart quantity selector
import 'package:flutter/material.dart';
class QuantitySelector extends StatefulWidget {
final int initialValue;
final ValueChanged<int> onChanged;
const QuantitySelector({
super.key,
this.initialValue = 1,
required this.onChanged,
});
@override
State<QuantitySelector> createState() => _QuantitySelectorState();
}
class _QuantitySelectorState extends State<QuantitySelector> {
late int _count;
@override
void initState() {
super.initState();
_count = widget.initialValue;
}
@override
void didUpdateWidget(covariant QuantitySelector oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialValue != widget.initialValue) {
_count = widget.initialValue;
}
}
void _increment() {
setState(() {
_count = (_count + 1).clamp(1, 99);
});
widget.onChanged(_count);
}
void _decrement() {
setState(() {
_count = (_count - 1).clamp(1, 99);
});
widget.onChanged(_count);
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.remove), onPressed: _decrement),
Text('$_count', style: const TextStyle(fontSize: 18)),
IconButton(icon: const Icon(Icons.add), onPressed: _increment),
],
),
);
}
}
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
5. Event Handling
(1) GestureDetector vs InkWell
| Widget | Click Effect | Ripple | Use Case |
|---|---|---|---|
GestureDetector |
No visual feedback | No | Custom gestures (long press/drag/double tap) |
InkWell |
Material ripple | Yes | Card/list item clicks |
IconButton |
Icon + ripple | Yes | Toolbar actions |
ElevatedButton |
Button style | Yes | Primary actions |
▶ Example
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
: GestureDetector gesture recognition
import 'package:flutter/material.dart';
// Simplified class definition
class Product {
final String name;
final double price;
final String imageUrl;
final int id;
const Product({required this.name, required this.price, required this.imageUrl, required this.id});
}
GestureDetector(
onTap: () => debugPrint('Tapped'),
onDoubleTap: () => debugPrint('Double tapped'),
onLongPress: () => debugPrint('Long pressed'),
onHorizontalDragEnd: (details) {
// Swipe to add/remove from cart
if (details.primaryVelocity! < 0) {
addToCart(product); // Swipe left
} else {
removeFromCart(product); // Swipe right
}
},
child: ProductCard(product: product),
)
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
▶ Example
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
: InkWell product card click
import 'package:flutter/material.dart';
// Product class definition shown above
InkWell(
onTap: () => Navigator.pushNamed(context, '/product/${product.id}'),
borderRadius: BorderRadius.circular(12),
child: Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Image.network(product.imageUrl, width: 60, height: 60),
const SizedBox(width: 12),
Expanded(child: Text(product.name)),
Text('$${product.price.toStringAsFixed(2)}'),
],
),
),
),
)
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
6. Input Interaction: TextField
(1) TextEditingController
| Feature | Method |
|---|---|
| Get text | controller.text |
| Set text | controller.text = 'new' |
| Listen for changes | controller.addListener(fn) |
| Select text | controller.selection |
| Clear input | controller.clear() |
▶ Example
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
: Search bar implementation
import 'package:flutter/material.dart';
class SearchBar extends StatefulWidget {
final ValueChanged<String> onSearch;
const SearchBar({super.key, required this.onSearch});
@override
State<SearchBar> createState() => _SearchBarState();
}
class _SearchBarState extends State<SearchBar> {
final _controller = TextEditingController();
@override
void initState() {
super.initState();
_controller.addListener(() {
widget.onSearch(_controller.text);
});
}
@override
void dispose() {
_controller.dispose(); // Important: prevent memory leak
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Search products...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
widget.onSearch('');
},
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
}
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
7. Complete Example: ShopApp Cart Page
import 'package:flutter/material.dart';
class CartPage extends StatefulWidget {
const CartPage({super.key});
@override
State<CartPage> createState() => _CartPageState();
}
class _CartPageState extends State<CartPage> {
final List<CartItem> _items = [
CartItem(name: 'Pro Laptop', price: 1299.99, quantity: 1),
CartItem(name: 'Wireless Mouse', price: 29.99, quantity: 2),
CartItem(name: 'USB-C Hub', price: 49.99, quantity: 1),
];
double get _total => _items.fold(0.0, (sum, item) => sum + item.price * item.quantity);
int get _itemCount => _items.fold(0, (sum, item) => sum + item.quantity);
void _updateQuantity(int index, int newQty) {
setState(() {
if (newQty <= 0) {
_items.removeAt(index);
} else {
_items[index] = _items[index].copyWith(quantity: newQty);
}
});
}
void _removeItem(int index) {
setState(() => _items.removeAt(index));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Cart ($_itemCount items)')),
body: _items.isEmpty
? const Center(child: Text('Your cart is empty'))
: Column(
children: [
Expanded(
child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, index) {
final item = _items[index];
return ListTile(
leading: const Icon(Icons.shopping_bag),
title: Text(item.name),
subtitle: Text('$${item.price.toStringAsFixed(2)} each'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.remove_circle_outline),
onPressed: () => _updateQuantity(index, item.quantity - 1)),
Text('${item.quantity}', style: const TextStyle(fontSize: 16)),
IconButton(icon: const Icon(Icons.add_circle_outline),
onPressed: () => _updateQuantity(index, item.quantity + 1)),
IconButton(icon: const Icon(Icons.delete_outline, color: Colors.red),
onPressed: () => _removeItem(index)),
],
),
);
},
),
),
// Total bar
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.grey[100],
border: Border(top: BorderSide(color: Colors.grey[300]!))),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [
const Text('Total', style: TextStyle(fontSize: 12)),
Text('$${_total.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
]),
ElevatedButton(
onPressed: _items.isEmpty ? null : () {},
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16)),
child: const Text('Checkout'),
),
],
),
),
],
),
);
}
}
class CartItem {
final String name;
final double price;
final int quantity;
const CartItem({required this.name, required this.price, required this.quantity});
CartItem copyWith({String? name, double? price, int? quantity}) =>
CartItem(name: name ?? this.name, price: price ?? this.price, quantity: quantity ?? this.quantity);
}
> Output: Run locally with Flutter SDK (Flutter 3.x / Dart 3.x). The Piston server does not have Flutter installed; use `flutter run` on your local machine for comparison. Actual UI/state may vary slightly across platforms.
❓ FAQ
WidgetsBinding.instance.addPostFrameCallback.behavior: HitTestBehavior.opaque to ensure the entire area is clickable.controller.dispose() in the State's dispose method, otherwise it will cause a memory leak.📖 Summary
- StatefulWidget has a complete lifecycle: initState → build → dispose
- setState is the only way to trigger UI updates: changing state without calling setState won't update the UI
- GestureDetector supports rich gestures; InkWell provides Material ripple feedback
- TextEditingController manages input field state and must be disposed in the dispose method
- The cart quantity selector is a classic application of setState + callback passing
📝 Exercises
- Basic (difficulty ⭐): Create a counter page with "add/subtract/reset" buttons and a centered number display.
- Intermediate (difficulty ⭐⭐): Implement a search bar that filters a list in real time as you type (using setState + TextEditingController).
- Challenge (difficulty ⭐⭐⭐): Implement a complete shopping cart: support quantity adjustment, item removal, automatic total calculation, and show "Cart is empty" for the empty state.