Have you ever written an async
function inside initState()
to fetch data or show a loading indicator only to find your UI doesn’t update or crashes with a cryptic error?
You’re not alone. Almost every Flutter dev has been there.
The real issue?
You’re using initState()
wrong and Dart’s event loop + widget lifecycle aren’t forgiving about it.
This blog will help you understand what’s really happening, teach you the right way to handle async logic in initState()
, and walk through real-world examples and fixes.
Let’s fix that weird bug for good.
First, What Is initState()
Really?
In Flutter, initState()
is a lifecycle method that’s called once when your StatefulWidget
is inserted into the widget tree.
It’s the perfect place to:
- Initialize variables
- Start animations
- Setup controllers
- Trigger data fetching
But here’s the catch:
It must remain synchronous.
You can’t mark initState()
as async
and if you try to await
something inside it, you might get strange bugs like:
- setState called after dispose
- UI not rebuilding
- App freezes or doesn’t respond
What Not to Do
Let’s say you want to fetch data when the screen opens:
@override
void initState() {
super.initState();
fetchData(); // async function
}
Future<void> fetchData() async {
final data = await api.getData();
setState(() {
_data = data;
});
}
Sounds reasonable, right? But here’s what could go wrong:
- Your UI might rebuild before data is ready.
- If the widget is disposed while the async call is still running,
setState()
throws an error. - A loading spinner might not even show until the data is fetched.
The Right Way: Use addPostFrameCallback()
To make sure your async code runs after the first build, use:
WidgetsBinding.instance.addPostFrameCallback((_) {
fetchData();
});
Now your initState()
stays sync, and your async logic starts after the widget is on-screen.
Example:
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadInitialData();
});
}
Future<void> _loadInitialData() async {
setState(() => _isLoading = true);
final result = await api.getData();
if (!mounted) return; // avoid calling setState after dispose
setState(() {
_data = result;
_isLoading = false;
});
}
Real-World Case: Spinner Doesn’t Show
You write this:
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() => _loading = true);
await Future.delayed(Duration(seconds: 2)); // simulate API call
setState(() {
_loading = false;
_data = ['A', 'B', 'C'];
});
}
But on running it, the spinner doesn’t show!
Why? Because the UI hasn’t had time to build before the async work blocks it.
Fix: Let the UI build first
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadData();
});
}
Now your loading indicator appears immediately, and the UI updates smoothly.
Best Practices for initState()
and Async
Do
- Keep
initState()
synchronous - Use
addPostFrameCallback
to trigger async task - Check
mounted
before callingsetState()
after async - Show loading indicators clearly
Don’t
- Mark
initState()
as async - Call
await
directly ininitState()
- Assume your widget is always alive
- Run long tasks before first build
Bonus Tip: Use FutureBuilder
for Stateless Data Loading
If you’re just fetching something once and don’t need full-blown state management, FutureBuilder
might be simpler:
FutureBuilder<List<String>>(
future: api.getItems(),
builder: (context, snapshot) {
if (!snapshot.hasData) return CircularProgressIndicator();
return ListView(
children: snapshot.data!.map(Text.new).toList(),
);
},
)
But be cautious FutureBuilder
runs the future every time the widget rebuilds unless cached.
Summary: What You Learned
-
initState()
runs before your widget is on screen don’t block it - Async code should be scheduled using
addPostFrameCallback
or similar techniques - Always check
mounted
before callingsetState()
afterawait
- Flutter lifecycle + Dart’s event loop = weird bugs unless handled correctly
Final Thoughts
Understanding initState()
and how async fits into the Flutter lifecycle is a game-changer. It prevents flaky UI, unexpected errors, and improves performance.
Next time your spinner doesn’t spin, or your UI feels stuck now you know where to look.
More Blogs Like This
If you liked this one, check out my last post: Understanding Dart’s Event Loop: Why Your Async Code Acts Weird