How to Use Stateful and Stateless Widgets in Flutter: Complete Guide
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 does not — but the lifecycle that governs how widgets are created, updated, and destroyed has a direct impact on performance, memory usage, and correctness in your Flutter applications. This complete step by step beginner 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 specifically for Mobo mobile app development.
What You'll Learn:
- How to distinguish between StatelessWidget and StatefulWidget
- How the Flutter widget lifecycle works in execution order
- How to correctly initialize controllers and subscriptions in initState
- How to dispose resources and prevent memory leaks
- How to handle asynchronous operations without crashing your app
- How to avoid the four most common setState pitfalls
- How to build a production-ready pull-to-refresh list with pagination
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.
Prevent Memory Leaks
Cancel streams, controllers, and timers in dispose() to prevent resource leaks that crash production apps after extended use.
Avoid Duplicate API Calls
Initiate data fetches in initState rather than build to prevent re-firing requests on every widget rebuild.
Optimize Rebuilds
Extract const widgets and use keys correctly to minimize unnecessary rebuilds and improve rendering performance.
Ship Production-Grade Apps
Build apps like Mobo where dozens of screens share connection state, controllers, and backend session data without leaking resources.
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 include typography wrappers, icon badges, card layouts, and formatters. Use StatefulWidget when the widget owns mutable data — text controllers, scroll positions, toggle flags, animation controllers, or asynchronous operation results.
The Stateless Widget Lifecycle
Stateless widgets have the simplest lifecycle possible. They have exactly one method that matters: build(). The build method 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.
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,
),
);
}
}
The const constructor is essential. Without it, Flutter cannot deduplicate identical instances, leading to unnecessary widget rebuilds and reduced performance.
The Stateful Widget Lifecycle in Order
The Stateful widget lifecycle has multiple phases. Understanding the exact order is essential to writing correct code that initializes properly, reacts to changes, and cleans up after itself.
| # | 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 dependency 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 |
Step by Step: Building a Counter Widget
The following example demonstrates every lifecycle method in action. Run this widget and observe the console log to see the exact order of execution.
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 console log. You will see initState > didChangeDependencies > build on first insertion, then build after every tap, and finally deactivate > dispose when the widget is removed from the tree.
The Right Place for Common Operations
Knowing which lifecycle method to use for each type of operation prevents bugs and ensures your code runs reliably. Use this reference table when deciding where to place initialization and cleanup logic.
| 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 or async calls | initState (fire and store the Future) |
Context-Dependent Calls
Calling Theme.of(context) inside initState will throw an exception. The BuildContext is not yet fully wired to its ancestors. Always defer context-dependent work to didChangeDependencies or build. Note that context.read() from flutter_bloc or Provider is safe in initState because it looks up the widget tree without registering a dependency.
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. Storing the Future in a field — not calling the fetch method 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.
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!);
},
),
);
}
}
Disposing Resources Correctly
Every controller, stream subscription, animation, and timer must be disposed. Forgetting to do so is the single most common source of memory leaks in Flutter apps. The following example shows the correct disposal pattern for a search field with debouncing.
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);
}
}
Production Disposal
In a production codebase like Mobo, 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. Every subscription must have a corresponding cancellation in dispose().
Common setState Pitfalls
setState looks innocent, but several edge cases trip up new developers regularly. Here are the four most common pitfalls and how to avoid each one.
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. Always check mounted before calling setState in any asynchronous callback.
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 on the current frame. Keep the callback to assignments only.
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.
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 does not reflect your change, this is the first suspect. Always wrap state mutations that should trigger a UI update inside setState.
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. This example demonstrates proper pagination with pull-to-refresh, scroll-based loading, and error handling.
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. This is the exact pattern used in production Mobo app delivery screens.
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.
Frequently Asked Questions
What is the difference between StatelessWidget and StatefulWidget in Flutter?
StatelessWidget is immutable and depends only on constructor arguments, with only a build() method. StatefulWidget pairs with a mutable State object that persists across rebuilds and has a full lifecycle including initState, didUpdateWidget, dispose, and the ability to trigger UI updates via setState.
When should I use initState versus didChangeDependencies?
Use initState for one-time setup like initializing controllers and firing async fetches. Use didChangeDependencies when you need to read InheritedWidget values like Theme.of(context) or MediaQuery.of(context), as the context is not fully wired during initState.
What causes the "setState called after dispose" error in Flutter?
This error occurs when an asynchronous callback completes after the widget has already been removed from the tree. The fix is to check the mounted property before calling setState: if (!mounted) return; setState(() { ... });
How do I properly cancel streams and timers in Flutter dispose?
Store all stream subscriptions, controllers, and timers as fields, then cancel or dispose each one inside the dispose() method in reverse order of initialization. Always call super.dispose() at the end to ensure Flutter's own cleanup runs correctly.
Can I call Theme.of(context) inside initState in Flutter?
No. Theme.of(context) calls dependOnInheritedWidgetOfExactType, which requires a fully wired BuildContext. The context is not ready during initState. Use didChangeDependencies or defer context-dependent reads to the build method instead.
Need Help with Flutter Mobile Development?
Our Flutter experts can help you build production-grade mobile apps with clean widget architecture, proper lifecycle management, and seamless backend integration for your business.
About the author
Braincuber Editorial Team
Combined output from Braincuber's practice leads — Odoo, AI agents, AWS — synthesizing real deployment data from 500+ shipped projects.
