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

Md. Al - AminMd. Al - Amin
4 min read

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 tasks

  • Check mounted before calling setState() after async

  • Show loading indicators clearly

Don’t

  • Mark initState() as async

  • Call await directly in initState()

  • 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 calling setState() after await

  • 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

0
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.