Local State
Managing ephemeral state with StatefulWidget and setState() β the foundation of interactive Flutter apps.
In the previous topic, we learned that state can be split into two types: ephemeral state (also called local or UI state) and app state .
π¦ Local (Ephemeral) State
Local state is state that is neatly contained in a single widget and doesn't need to be shared with other parts of your app. It's temporary, local to a specific widget subtree, and doesn't need to be persisted across app restarts.
-
β Managed with
StatefulWidgetandsetState() - β Perfect for UI interactions like toggles, animations, and form inputs
- β No need for complex state management solutions
A
StatefulWidget
is a widget that can
change its appearance
in response to
events triggered by user interactions or data changes. It does this by maintaining a separate
State
object that holds the mutable state.
// 1. The StatefulWidget class (immutable)
class
CounterWidget
extends
StatefulWidget {
const
CounterWidget({super.key});
@override
State<CounterWidget>
createState
() => _CounterWidgetState();
}
// 2. The State class (mutable β holds the state)
class
_CounterWidgetState
extends
State<CounterWidget> {
int
_count =
0
;
// π This is the local state
void
_increment
() {
setState(() {
_count++;
// π Changing state inside setState
});
}
@override
Widget
build
(BuildContext context) {
return
Row(
children: [
Text(
'$_count'
),
ElevatedButton(
onPressed: _increment,
child: Text(
'Add'
),
),
],
);
}
}
β Two Classes, One Purpose
Every
StatefulWidget
comes in two parts: the
widget class
(immutable, defines
the configuration) and the
state class
(mutable, holds the state and builds the UI).
The framework manages the state object and calls
build()
whenever the state changes.
setState()
is the method you call to tell the framework that the state of your widget has changed.
When you call
setState()
, two things happen:
- 1. The code inside the callback runs (where you update your state variables)
-
2.
The framework schedules a rebuild of the widget (calls
build()again)
β οΈ Critical Rule
Only call
setState()
inside the State class.
Never call it from outside the State class.
Also,
never call
setState()
in
initState()
or
build()
β this will cause infinite rebuild loops.
How setState() Works
π The Rebuild Flow
- User interaction triggers an event (e.g., button tap)
-
Event handler calls
setState()with a callback - Inside the callback: you update the state variables
- After the callback: the framework marks the widget as "dirty"
-
Next frame:
the framework calls
build()to rebuild the widget with the new state - UI updates to reflect the new state
Let's look at a complete, runnable example of a counter app that uses local state. This is the classic "Hello World" of state management.
import
'package:flutter/material.dart'
;
void
main
() => runApp(
MyApp
());
class
MyApp
extends
StatelessWidget {
@override
Widget
build
(BuildContext context) {
return
MaterialApp(
home:
CounterScreen
(),
);
}
}
class
CounterScreen
extends
StatefulWidget {
@override
State<CounterScreen>
createState
() => _CounterScreenState();
}
class
_CounterScreenState
extends
State<CounterScreen> {
int
_counter =
0
;
// π Local state
void
_incrementCounter
() {
setState(() {
_counter++;
// Update the state
});
}
void
_decrementCounter
() {
setState(() {
if
(_counter >
0
) _counter--;
});
}
void
_resetCounter
() {
setState(() {
_counter =
0
;
});
}
@override
Widget
build
(BuildContext context) {
return
Scaffold(
appBar: AppBar(title: Text(
'Counter App Pro'
)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'You have pushed the button:'
),
Text(
'$_counter'
,
style: Theme.of(context).textTheme.headlineLarge,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _incrementCounter,
child: Text(
'Increment'
),
),
SizedBox(width:
8
),
ElevatedButton(
onPressed: _decrementCounter,
child: Text(
'Decrement'
),
),
SizedBox(width:
8
),
ElevatedButton(
onPressed: _resetCounter,
child: Text(
'Reset'
),
),
],
),
],
),
),
);
}
}
CounterScreen
extends
StatefulWidget
.
It's the immutable configuration of the widget.
_CounterScreenState
)
that will manage the mutable state for this widget.
_CounterScreenState
holds the mutable state.
The underscore (
_
) makes it private to the library.
_counter
is the local state. It's initialized to 0 and
changed only inside
setState()
callbacks.
_incrementCounter
,
_decrementCounter
,
and
_resetCounter
wrap their state changes in
setState()
.
_counter
to display the current count.
When
setState()
is called, this method is reβexecuted with the updated
_counter
value.
Understanding the lifecycle of a
StatefulWidget
is crucial for managing resources
and avoiding bugs. Here are the most important lifecycle methods:
β οΈ Important: initState() and dispose()
Always override
initState()
and
dispose()
in pairs.
Use
initState()
to initialize controllers, listeners, or animations.
Use
dispose()
to clean them up and prevent memory leaks.
Never call
setState()
in
initState()
β the widget hasn't been built yet.
You cannot call
setState()
from outside the State class. Keep all state logic inside the State class.
All state changes should be triggered from within the State class, typically in event handlers.
If multiple widgets need access to the same data, that's
app state
, not local state.
Using
setState()
for app state leads to prop drilling and hard-to-maintain code.
For state that needs to be shared across widgets, use a state management solution like Riverpod, Provider, or BLoC. We'll cover this in later topics.
This causes an infinite rebuild loop. The widget rebuilds, which triggers
setState()
,
which triggers another rebuild, and so on.
Use
initState()
to initialize controllers, streams, or animations. Use event handlers
(like button callbacks) to call
setState()
.
β Use Local State When
- The state is only needed within a single widget
- No other widget needs to know about the state
- The state is temporary (e.g., animation progress, toggle state)
- You don't need to persist the state between sessions
Example: Checkbox state, TabController index, TextField input
π Use App State When
- Multiple widgets need access to the same data
- State needs to be persisted across sessions
- State changes need to update multiple parts of the UI
- The state is complex and requires structured management
Example: User authentication, shopping cart, app settings
π‘ Pro Tip: Start with Local State, Evolve When Needed
It's perfectly fine to start with
StatefulWidget
and
setState()
.
When you find yourself passing callbacks down multiple levels or duplicating state, it's time to
consider a state management solution.
Don't over-engineer prematurely.
π― Key Takeaway
Local state is managed with
StatefulWidget
and
setState()
.
It's perfect for UI interactions that don't need to be shared across widgets.
Use it for what it's good for, and reach for a state management solution when your app grows.
When in doubt, ask: "Does another widget need to know about this state?"