← Back to blog
Flutter Fundamentals · Part 3 of 9
July 17, 202611 min read

Stateless vs Stateful Widgets in Flutter: When to Use Which

FlutterDart

Stateless vs Stateful Widgets

This is Part 3 of the Flutter Fundamentals series. In Part 2 we learned that a StatefulWidget's State lives on the persistent Element, which is why it survives rebuilds. Now we make that practical: what's the actual difference between the two widget kinds, what's the State lifecycle, and — the daily question — which one should you reach for?

This trips up beginners constantly, but the rule is genuinely simple once you anchor it to one idea: does this widget need to remember something that changes over its lifetime?


The core distinction in one line

Stateless = draws once from the inputs it's given, and only changes if those inputs change. Stateful = can hold internal data that changes over time and rebuild itself when it does.

A StatelessWidget is like a printed photograph — fixed the moment it's made. To show something different, you print a new photo (a new widget from new inputs).

A StatefulWidget is like a whiteboard — it stays on the wall (its State persists) and you can erase and rewrite it as things change, and everyone looking sees the update.


StatelessWidget: pure description

A StatelessWidget has only final fields (its inputs) and a single build method. Given the same inputs, it always produces the same UI. It cannot change itself.

class Greeting extends StatelessWidget {
  final String name; // inputs are final — set once, never mutated

  const Greeting({super.key, required this.name});

  @override
  Widget build(BuildContext context) {
    return Text('Hello, $name!');
  }
}

Greeting('Vivek') will always render "Hello, Vivek!". If the parent wants a different name, it builds a new Greeting with a new value — the widget itself never mutates. That's the whole contract: immutable inputs in, UI out.

Use it for anything that just displays what it's given: labels, icons, a custom card that takes data via its constructor, layout wrappers, most of your "dumb" presentational widgets.


StatefulWidget: description + memory

A StatefulWidget comes in two classes: the widget (immutable, like always) and a separate State object that holds the mutable data and lives on the Element across rebuilds (Part 2).

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _count = 0; // mutable state — survives rebuilds (it lives on the Element)

  void _increment() {
    setState(() {
      _count++; // change the data...
    });          // ...and tell Flutter to rebuild this widget
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('Count: $_count'),
        ElevatedButton(onPressed: _increment, child: const Text('+1')),
      ],
    );
  }
}

Why two classes? Because the widget must stay immutable (Flutter recreates it every rebuild — Part 2), but something needs to remember _count. That something is the State object, which Flutter keeps alive on the Element. The widget is disposable; the State is durable.


setState: the only way to trigger a rebuild

setState does two things, and the order matters:

  1. Runs the callback you pass it, where you mutate your state fields.
  2. Marks this Element dirty, scheduling a build on the next frame.
void _increment() {
  setState(() => _count++); // mutate inside, so Flutter rebuilds with the new value
}

Two rules that prevent 90% of setState bugs:

  • Mutate state inside the setState callback. Changing _count outside it updates the variable but never schedules a rebuild, so the UI silently goes stale.
  • setState only rebuilds this widget (and its descendants), not the whole app. It's targeted.

⚠️ Don't call setState during build, and don't call it after the widget is gone. Recall from the async series: after an await, guard with if (!mounted) return; before setState, or you'll crash with "setState called after dispose".


The State lifecycle

The State object has a lifecycle — hook methods Flutter calls at specific moments. The four you'll use most:

class _MyState extends State<MyWidget> {
  @override
  void initState() {
    super.initState();
    // Called ONCE when the State is created and inserted into the tree.
    // Set up: subscriptions, controllers, initial data fetch.
  }

  @override
  void didUpdateWidget(MyWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    // Called when the PARENT rebuilds and gives this State a new widget config.
    // React to changed inputs: if (widget.id != oldWidget.id) refetch();
  }

  @override
  Widget build(BuildContext context) => const SizedBox();

  @override
  void dispose() {
    // Called ONCE when the State is permanently removed.
    // Tear down EVERYTHING from initState: controllers, stream subscriptions.
    super.dispose();
  }
}

| Method | When | Use it for | | --- | --- | --- | | initState() | once, on creation | set up controllers, subscriptions, initial fetch | | didUpdateWidget() | parent rebuilt with new config | react to changed input props | | build() | every rebuild | describe the UI (no side effects!) | | dispose() | once, on removal | clean up controllers/subscriptions to avoid leaks |

🔑 initState/dispose come in pairs. Anything you create or subscribe to in initState (an AnimationController, a StreamSubscription, a TextEditingController) must be torn down in dispose. This is the #1 source of Flutter memory leaks — and it ties straight back to cancelling stream subscriptions from the async series.

late final TextEditingController _controller;

@override
void initState() {
  super.initState();
  _controller = TextEditingController(); // created here...
}

@override
void dispose() {
  _controller.dispose(); // ...destroyed here. Always pair them.
  super.dispose();
}

There's a subtle but important detail here you can verify yourself: a hot reload re-runs build but does not re-run initState (Part 7 explains exactly why).


How to decide: a flowchart

When you create a widget, ask one question:

Does this widget need to remember data that CHANGES over its lifetime,
and update the screen when it does?
│
├── No  → StatelessWidget
│         (it just renders what it's handed: labels, layout, cards-from-data)
│
└── Yes → does the changing data belong to THIS widget specifically?
          ├── Yes → StatefulWidget with setState
          │         (a toggle, a counter, an animation, a text field, a form)
          └── Shared across many widgets → lift it up / use a state-management
              solution (Provider, Riverpod, Bloc) — beyond this series

Concretely:

| Use Stateless when… | Use Stateful when… | | --- | --- | | showing data passed in via the constructor | a value changes due to user interaction (toggle, counter) | | building static layout / styling | you run an animation (needs a controller) | | a "presentational" card, tile, header | you manage a text field / form input | | nothing in it ever changes on its own | you must initState/dispose something (timer, stream) |

Default to Stateless. Reach for Stateful only when you have a concrete reason (local changing data, a lifecycle to manage). Stateless widgets are simpler, cheaper, and easier to reason about. A common pattern: a Stateful parent owns the data and passes it down to many Stateless children.


A common beginner mistake

Don't store changing data in a StatelessWidget field and expect it to update — it can't, and even if it compiled, the widget gets recreated so the value resets:

// ❌ WRONG: a StatelessWidget can't hold changing state.
class BrokenCounter extends StatelessWidget {
  int count = 0; // even ignoring the 'final' lint, this never updates the UI

  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => count++, // mutates a field, but nothing rebuilds → UI frozen
      child: Text('$count'),
    );
  }
}

Tapping increments count in memory, but there's no setState, no Element to remember it, and the widget is recreated from scratch on the next parent rebuild — so the screen never changes. This must be a StatefulWidget (or the count must be lifted into a parent's State).


Practice Challenges

Challenge 1 — Classify them. Stateless or Stateful? (a) a PriceTag that shows a number passed in; (b) a checkbox that toggles on tap; (c) a screen running a loading spinner animation; (d) an app logo.

Show solution

(a) Stateless — renders an input. (b) Stateful — its checked value changes on interaction. (c) Stateful — an animation needs a controller created in initState and disposed. (d) Stateless — static.

Challenge 2 — Build a toggle. Write a StatefulWidget that shows "ON"/"OFF" and flips on tap.

Show solution
class Toggle extends StatefulWidget {
  const Toggle({super.key});
  @override
  State<Toggle> createState() => _ToggleState();
}

class _ToggleState extends State<Toggle> {
  bool _on = false;
  @override
  Widget build(BuildContext context) {
    return TextButton(
      onPressed: () => setState(() => _on = !_on),
      child: Text(_on ? 'ON' : 'OFF'),
    );
  }
}

Challenge 3 — Fix the leak. This State subscribes to a stream in initState but the app leaks memory. Fix it.

@override
void initState() {
  super.initState();
  _sub = clock.stream.listen((t) => setState(() => _time = t));
}
Show solution

Cancel the subscription in dispose (and guard setState with mounted):

@override
void dispose() {
  _sub.cancel();
  super.dispose();
}

initState and dispose must pair up — anything subscribed/created in one is torn down in the other.

Challenge 4 — Why doesn't this update? A StatelessWidget mutates a field on tap but the UI never changes. Explain in terms of Part 2.

Show solution

A StatelessWidget has no State object and no setState, so mutating a field never marks the Element dirty — no rebuild is scheduled. And because widgets are immutable and recreated each parent rebuild (Part 2), the field resets anyway. Changing data that drives the UI must live in a StatefulWidget's State (held by the persistent Element) and be updated via setState.


Questions to test yourself

Q1 (basic). In one line each, what's the difference between a StatelessWidget and a StatefulWidget?

Show answer

A StatelessWidget renders purely from its (immutable) inputs and can't change itself. A StatefulWidget has an associated State object that can hold data changing over time and rebuild the widget (via setState) when it does.

Q2 (basic). Why does a StatefulWidget split into two classes?

Show answer

Because the widget itself must stay immutable (Flutter recreates it every rebuild), but something must remember the mutable data. The separate State object holds that data and is kept alive on the persistent Element across rebuilds, while the widget is disposable.

Q3 (intermediate). What two things does setState do, and what's the classic mistake?

Show answer

It (1) runs your callback where you mutate state fields, and (2) marks the Element dirty to schedule a rebuild. The classic mistake is mutating state outside the setState callback — the variable changes but no rebuild is scheduled, so the UI silently goes stale.

Q4 (intermediate). What do initState and dispose do, and why are they a pair?

Show answer

initState runs once when the State is created — set up controllers, subscriptions, initial fetches. dispose runs once when the State is permanently removed — tear those things down. They pair because anything created/subscribed in initState must be cleaned up in dispose, or you leak memory (the #1 Flutter leak source).

Q5 (intermediate). When is didUpdateWidget called, and what's it for?

Show answer

It's called when the parent rebuilds and supplies this State a new widget configuration (new constructor inputs). Use it to react to changed input props — e.g. if (widget.userId != oldWidget.userId) _refetch();. The State persists across these updates, so you compare old vs new.

Q6 (advanced). You have a counter shared across three sibling widgets. Should each be Stateful? What's the better design?

Show answer

No — don't duplicate the state. Lift the state up to a common ancestor: make the shared value live in one place (a Stateful parent, or a state-management solution like Provider/Riverpod/Bloc) and pass it down to the siblings (which can be Stateless). A single source of truth keeps the three in sync; multiple independent States would drift. "Default to Stateless; own changing data in exactly one place."


Wrapping up

Picking the right widget kind comes down to one question — does it need to remember changing data?

  • StatelessWidget — immutable inputs in, UI out; can't change itself. Default to this.
  • StatefulWidget — a durable State object (on the Element) holds mutable data; setState mutates it and schedules a rebuild.
  • The lifecycle: initState (set up) → build (describe, repeatedly) → didUpdateWidget (react to new inputs) → dispose (clean up). Pair initState/dispose.
  • Mutate state inside setState; guard with mounted after awaits; clean up in dispose.
  • Shared state → lift it up to one source of truth.

We've now mentioned build a dozen times as "the method that describes the UI." But when exactly does Flutter call it — and how often? Getting that wrong is the source of mysterious performance problems. Part 4 zooms all the way into the build method: what it does and when it gets called.