Enable Dark Mode!
an-introduction-to-stateful-and-stateless-widgets-in-flutter-for-mobo-development.jpg
By: Muhammed Shehzad K

An Introduction to Stateful and Stateless Widgets in Flutter for Mobo Development

Technical mobo Flutter

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.

AspectStatelessWidgetStatefulWidget
Internal stateNoneHeld in State<T>
Rebuild triggerParent passes new configsetState() or parent rebuild
Lifecycle methodsbuild() onlyFull lifecycle (initState, dispose, etc.)
Memory costMinimalHigher — State object retained
Use casePure UI, derived dataForms, 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

#MethodWhen It Runs
1createState()Once, when the StatefulWidget is inserted into the tree.
2

initState()

Once, immediately after the State is created.
3didChangeDependencies()After initState, and again when an InheritedWidget it depends on changes.
4build()After initState/didChangeDependencies, and after every setState or parent rebuild.
5didUpdateWidget()When the parent rebuilds with a new widget configuration.
6deactivate()When the widget is temporarily removed from the tree.
7dispose()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

OperationCorrect Method
One-time initialization (controllers, listeners)initState
Subscribing to streamsinitState or didChangeDependencies
Reading InheritedWidget / Theme.of(context) for first timedidChangeDependencies

Reacting to new parent arguments

didUpdateWidget
Cancelling streams, disposing controllersdispose
Triggering rebuildssetState
Network/async callsinitState (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

FeaturePurposeMobile Benefit
StatelessWidgetPure, configuration-driven UICheap rebuilds, easy const reuse
StatefulWidgetUI with mutable internal stateForms, animations, async data
initStateOne-time setupAvoids duplicate fetches
didChangeDependenciesReact to inherited changesPicks up theme/locale safely
didUpdateWidgetReact to new parent argumentsSync internal state with new config
disposeRelease resourcesPrevents memory leaks
mounted checkGuard async callbacksEliminates "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.


If you need any assistance in odoo, we are online, please chat with us.



0
Comments



Leave a comment



Recent Posts

whatsapp_icon
location

Calicut

Cybrosys Technologies Pvt. Ltd.
Neospace, Kinfra Techno Park
Kakkancherry, Calicut
Kerala, India - 673635

location

Kochi

Cybrosys Technologies Pvt. Ltd.
1st Floor, Thapasya Building,
Infopark, Kakkanad,
Kochi, India - 682030.

location

Bangalore

Cybrosys Techno Solutions
The Estate, 8th Floor,
Dickenson Road,
Bangalore, India - 560042

Send Us A Message