Skip to content
TaeyoungKim.dev

Flutter state changes but the UI stays the same: Check setState and build

AppWritten 3 min readTaeyoungKim
LinkedInX

Every button press increments _count to 1, 2, then 3, yet the UI keeps showing 0. Changing a value and telling Flutter to rebuild the widget that displays it are separate steps. A one-counter example makes the difference visible.

Why can a changed field leave the UI unchanged?

A StatefulWidget usually keeps mutable fields in its State object. build() reads those fields and returns a widget tree for the current state. Assigning a field directly does not, by itself, schedule another build.

The diagram compares the same underlying value. A direct field change leaves the existing UI in place; the setState path schedules a rebuild in which build() can display 2.

What is missing when the counter increments without setState?

This counter initially displays 0. Its button handler increments the field without announcing the change to Flutter:

dart
import 'package:flutter/material.dart';

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

  @override
  State<CountCard> createState() => _CountCardState();
}

class _CountCardState extends State<CountCard> {
  int _count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('$_count'),
        TextButton(
          onPressed: () {
            _count++; // Changes the field but does not schedule a rebuild.
          },
          child: const Text('Add'),
        ),
      ],
    );
  }
}

The field becomes 1, but this edit alone does not call build() again. If another event later rebuilds the widget, the UI may finally show 1. That delayed update can be misleading: the earlier assignment still did not schedule the rebuild.

What does setState do before build runs again?

Replace only the button's mutation with:

dart
onPressed: () {
  setState(() {
    _count++;
  });
},

setState() runs its callback synchronously, changing _count, and schedules this State to rebuild. When build() runs again, Text('$_count') reads the new value. Flutter's State.setState API explains why changing state without calling it may not schedule a rebuild.

setState() does not directly edit screen pixels. Keep its callback limited to the state change; do not put a slow request or an async callback inside it.

What if setState runs but the number still does not change?

Check which field build() actually reads. If the handler increments _count but the widget displays unchanged _displayCount, a rebuild will still show the old value:

dart
int _count = 0;
int _displayCount = 0;

// Even after setState(() { _count++; }), this stays the same:
Text('$_displayCount')

// The value this counter intended to show is:
Text('$_count')

Debug in order: Did the handler run? Did the displayed state change? Does build() read that state? Adding setState in unrelated places can hide the real mismatch.

What if the user leaves during an asynchronous operation?

A screen can be disposed while awaiting a response. Calling setState() on a disposed State is an error. Prefer cancelling requests or subscriptions when possible, and check mounted before applying an uncancellable result. Flutter documents the mounted boundary. This counter has no asynchronous request; the rule matters when adapting it to a real app.

Key takeaways

_count++ changes a field but does not itself schedule a rebuild. setState(() { _count++; }) announces the change, but the UI also needs build() to read _count. Trace the handler, state mutation, and displayed field in that order.

Author

TaeyoungKim

Connecting technical foundations with implementation, verification, and production decisions.

#Flutter#Dart#StatefulWidget#setState#build#State management#UI refresh

Read next