Every Flutter UI is built from widgets, and every widget is either Stateless or Stateful. The difference looks simple on the surface — one holds mutable state, the other doesn't — but the lifecycle that governs how widgets are created, updated, and destroyed has a direct impact on performance, memory usage, and correctness. This guide explores both widget types, walks through every lifecycle method in execution order, and demonstrates the real-world pitfalls of setState with practical Dart examples.
Why the Widget Lifecycle Matters for Mobile Developers?
In a small demo app, setState "just works." In a production app — one that handles authentication tokens, background API calls, animations, and offline caches — knowing exactly when a widget is mounted, rebuilt, and disposed becomes critical. Lifecycle bugs cause memory leaks, double-fired network requests, "setState called after dispose" exceptions, and animation jank.
Mastering the lifecycle helps you:
- Prevent memory leaks by cancelling streams, controllers, and timers in dispose().
- Avoid duplicate API calls by initiating fetches in initState, not build.
- Optimize rebuilds by extracting const widgets and using keys correctly.
- Reuse expensive resources by recognizing when Flutter recycles a State object.
- Ship production-grade apps like Mobo, where dozens of screens share connection state, controllers, and Odoo session data without leaking resources.
Prerequisites
Before diving in, make sure you have:
- Flutter SDK 3.x or later installed
- Basic knowledge of Dart classes and inheritance
- Familiarity with BuildContext, MaterialApp, and Scaffold
- A working project where you can run examples
1. Stateless vs Stateful — The Core Difference
A StatelessWidget is immutable. Once built, its appearance depends entirely on the configuration passed through its constructor. If the parent rebuilds with new arguments, Flutter creates a new instance.
A StatefulWidget is also immutable, but it pairs with a separate State object that *can* change over time. Flutter keeps the State alive across rebuilds, allowing the widget to hold values that survive parent reconfiguration.
| Aspect | StatelessWidget | StatefulWidget |
| Internal state | None | Held in State<T> |
| Rebuild trigger | Parent passes new config | setState() or parent rebuild |
| Lifecycle methods | build() only | Full lifecycle (initState, dispose, etc.) |
| Memory cost | Minimal | Higher — State object retained |
| Use case | Pure UI, derived data | Forms, animations, async data |
When to Use Each
- Use StatelessWidget when the widget's output depends only on its constructor arguments. Examples: typography wrappers, icon badges, card layouts, formatters.
- Use StatefulWidget when the widget owns mutable data — text controllers, scroll positions, toggle flags, animation controllers, or async operation results.
2. The Stateless Widget Lifecycle
Stateless widgets have the simplest lifecycle possible — they have exactly one method that matters: build().
class WelcomeBanner extends StatelessWidget {
final String userName;
const WelcomeBanner({super.key, required this.userName});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
color: Colors. blue.shade50,
child: Text(
'Welcome back, $userName',
style: Theme.of(context).textTheme.titleLarge,
),
);
}
}build() runs on first insertion and every time the parent rebuilds with potentially new arguments. Because the widget owns no state, Flutter is free to discard and recreate it cheaply. The const constructor is essential — without it, Flutter cannot deduplicate identical instances.
3. The Stateful Widget Lifecycle (In Order)
The Stateful widget lifecycle has multiple phases. Understanding the exact order is essential to writing correct code.
Lifecycle Method Order
| # | Method | When It Runs |
| 1 | createState() | Once, when the StatefulWidget is inserted into the tree. |
| 2 | initState() | Once, immediately after the State is created. |
| 3 | didChangeDependencies() | After initState, and again when an InheritedWidget it depends on changes. |
| 4 | build() | After initState/didChangeDependencies, and after every setState or parent rebuild. |
| 5 | didUpdateWidget() | When the parent rebuilds with a new widget configuration. |
| 6 | deactivate() | When the widget is temporarily removed from the tree. |
| 7 | dispose() | Once, when the widget is permanently removed |
A Walkthrough Example
class CounterCard extends StatefulWidget {
final int initialValue;
const CounterCard({super.key, required this.initialValue});
@override
State<CounterCard> createState() => _CounterCardState();
}
class _CounterCardState extends State<CounterCard> {
late int _count;
@override
void initState() {
super.initState();
_count = widget.initialValue;
debugPrint('initState: counter initialized to $_count');
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
debugPrint('didChangeDependencies: inherited data may have changed');
}
@override
void didUpdateWidget(covariant CounterCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialValue != widget.initialValue) {
_count = widget.initialValue;
debugPrint('didUpdateWidget: parent passed a new initialValue');
}
}
void _increment() {
setState(() => _count++);
}
@override
void deactivate() {
debugPrint('deactivate: widget removed (may be reinserted)');
super.deactivate();
}
@override
void dispose() {
debugPrint('dispose: widget permanently removed');
super.dispose();
}
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text('Count: $_count'),
trailing: IconButton(
icon: const Icon(Icons.add),
onPressed: _increment,
),
),
);
}
}Run this widget and observe the log. You'll see initState > didChangeDependencies > build on first insertion, then build after every tap, and finally deactivate > dispose when the widget is removed from the tree.
4. The Right Place for Common Operations
| Operation | Correct Method |
| One-time initialization (controllers, listeners) | initState |
| Subscribing to streams | initState or didChangeDependencies |
| Reading InheritedWidget / Theme.of(context) for first time | didChangeDependencies |
Reacting to new parent arguments | didUpdateWidget |
| Cancelling streams, disposing controllers | dispose |
| Triggering rebuilds | setState |
| Network/async calls | initState (fire and store the Future) |
Calling Theme.of(context) inside initState will throw — the BuildContext is not yet fully wired to its ancestors. Always defer context-dependent work to didChangeDependencies or build.
Note: context.read() from flutter_bloc or Provider is safe in initState because it looks up the widget tree without registering a dependency. What throws is Theme.of(context), MediaQuery.of(context), and Provider.of(context) — methods that call dependOnInheritedWidgetOfExactType and require a fully wired context.
5. The Asynchronous initState Pattern
Network requests in initState need careful handling because initState itself cannot be async. The standard pattern is to invoke an async helper and store the resulting Future:
class OrderDetailPage extends StatefulWidget {
final int orderId;
const OrderDetailPage({super.key, required this.orderId});
@override
State<OrderDetailPage> createState() => _OrderDetailPageState();
}
class _OrderDetailPageState extends State<OrderDetailPage> {
late Future<SaleOrder> _orderFuture;
@override
void initState() {
super.initState();
_orderFuture = _fetchOrder();
}
Future<SaleOrder> _fetchOrder() async {
final repo = context.read<SaleRepository>();
return repo.getOrderById(widget.orderId);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Order #${widget.orderId}')),
body: FutureBuilder<SaleOrder>(
future: _orderFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
return OrderView(order: snapshot.data!);
},
),
);
}
}Storing the Future in a field — not calling _fetchOrder() inside build — prevents the request from being re-fired on every rebuild. This is one of the most common performance bugs in early Flutter codebases.
6. Disposing Resources Correctly
Every controller, stream subscription, animation, and timer must be disposed. Forgetting this is the single most common source of memory leaks in Flutter apps.
class SearchField extends StatefulWidget {
const SearchField({super.key});
@override
State<SearchField> createState() => _SearchFieldState();
}
class _SearchFieldState extends State<SearchField> {
final _controller = TextEditingController();
StreamSubscription<String>? _querySubscription;
Timer? _debounceTimer;
@override
void initState() {
super.initState();
_controller.addListener(_onTextChanged);
}
void _onTextChanged() {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
context.read<SearchBloc>().add(QueryChanged(_controller.text));
});
}
@override
void dispose() {
_controller.removeListener(_onTextChanged);
_controller.dispose();
_querySubscription?.cancel();
_debounceTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(controller: _controller);
}
}In a production codebase like Mobo, where a single screen can wire up GPS listeners, scanner streams, and Odoo session refreshers, this disposal discipline is what keeps the app stable through hours of continuous field use.
7. setState Pitfalls
setState looks innocent, but several edge cases trip up new developers regularly.
Pitfall 1: Calling setState After dispose
// WRONG — throws if the widget was disposed before the future completed
Future<void> _loadData() async {
final data = await api.fetch();
setState(() => _items = data);
}
// CORRECT — guard with mounted
Future<void> _loadData() async {
final data = await api.fetch();
if (!mounted) return;
setState(() => _items = data);
}
The mounted flag is the standard way to guard against the widget being removed mid-await.
Pitfall 2: Heavy Work Inside setState
// WRONG — expensive work runs inside setState
setState(() {
_items = expensiveSortAndFilter(rawData);
});
// CORRECT — compute first, then update state
final computed = expensiveSortAndFilter(rawData);
setState(() => _items = computed);
setState only schedules a rebuild — work placed inside its callback still runs synchronously. Keep the callback to assignments.
Pitfall 3: Calling setState in build
Never call setState (or anything that triggers one) from inside build. It causes an infinite rebuild loop and Flutter will throw immediately. If you need to react to a state change after the frame, use:
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() { /* safe */ });
});Pitfall 4: Forgetting setState Entirely
Mutating a field without setState updates the value in memory but leaves the UI stale. If the screen doesn't reflect your change, this is the first suspect.
8. Real-World Example: Pull-to-Refresh List
Below is a complete example that combines initState, dispose, async loading, and setState guards — the exact pattern used by list screens in offline-first Odoo Flutter apps.
class DeliveryListPage extends StatefulWidget {
const DeliveryListPage({super.key});
@override
State<DeliveryListPage> createState() => _DeliveryListPageState();
}
class _DeliveryListPageState extends State<DeliveryListPage> {
final _scrollController = ScrollController();
List<Delivery> _deliveries = [];
bool _loading = false;
String? _error;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_fetchDeliveries();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_fetchDeliveries(loadMore: true);
}
}
Future<void> _fetchDeliveries({bool loadMore = false}) async {
if (_loading) return;
setState(() => _loading = true);
try {
final repo = context.read<DeliveryRepository>();
final offset = loadMore ? _deliveries.length : 0;
final fetched = await repo.getDeliveries(offset: offset, limit: 25);
if (!mounted) return;
setState(() {
_deliveries = loadMore ? [..._deliveries, ...fetched] : fetched;
_error = null;
_loading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Deliveries')),
body: RefreshIndicator(
onRefresh: () => _fetchDeliveries(),
child: ListView.builder(
controller: _scrollController,
itemCount: _deliveries.length + (_loading ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _deliveries.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
return DeliveryTile(delivery: _deliveries[index]);
},
),
),
);
}
}Every piece serves a purpose: initState kicks off the first fetch and registers the scroll listener, dispose cleans both up, setState is always guarded by mounted, and pagination flows through the same method without duplicating logic.
9. When to Use Each Lifecycle Feature
| Feature | Purpose | Mobile Benefit |
| StatelessWidget | Pure, configuration-driven UI | Cheap rebuilds, easy const reuse |
| StatefulWidget | UI with mutable internal state | Forms, animations, async data |
| initState | One-time setup | Avoids duplicate fetches |
| didChangeDependencies | React to inherited changes | Picks up theme/locale safely |
| didUpdateWidget | React to new parent arguments | Sync internal state with new config |
| dispose | Release resources | Prevents memory leaks |
| mounted check | Guard async callbacks | Eliminates "setState after dispose" |
The widget lifecycle is the foundation Flutter rests on. A StatelessWidget is a pure function of its inputs; a StatefulWidget adds a State object that persists across rebuilds and runs through a well-defined sequence of lifecycle methods. Mastering this sequence — and the pitfalls of setState — is what separates apps that simply run from apps that perform reliably under real-world conditions:
- Use StatelessWidget wherever output is purely a function of input.
- Use StatefulWidget for any mutable, time-dependent, or async behavior.
- Initialize once in initState, react to dependency or parent changes in their own callbacks, and always clean up in dispose.
- Guard every async setState with a mounted check.
Whether you're building a side project or shipping a multi-module Odoo companion suite like Mobo, these lifecycle fundamentals are what keep the app responsive, leak-free, and easy to evolve as features grow.
To read more about Implementing Clean Architecture for Mobo Flutter Applications, refer to our blog Implementing Clean Architecture for Mobo Flutter Applications.