Understanding Async, Await, and Flutter’s Lifecycle: Proper Use of initState()

Table of contents
- First, What Is initState() Really?
- What Not to Do
- Sounds reasonable, right? But here’s what could go wrong:
- The Right Way: Use addPostFrameCallback()
- Example:
- Real-World Case: Spinner Doesn’t Show
- Fix: Let the UI build first
- Best Practices for initState() and Async
- Bonus Tip: Use FutureBuilder for Stateless Data Loading
- Summary: What You Learned
- Final Thoughts
- More Blogs Like This

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()
synchronousUse
addPostFrameCallback
to trigger async tasksCheck
mounted
before callingsetState()
after asyncShow loading indicators clearly
Don’t
Mark
initState()
asasync
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 itAsync code should be scheduled using
addPostFrameCallback
or similar techniquesAlways 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
Subscribe to my newsletter
Read articles from Md. Al - Amin directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Md. Al - Amin
Md. Al - Amin
Experienced Android Developer with a demonstrated history of working for the IT industry. Skilled in JAVA, Dart, Flutter, and Teamwork. Strong Application Development professional with a Bachelor's degree focused in Computer Science & Engineering from Daffodil International University-DIU.