Flutter tutorials and courses by Andrea Bizzotto

Side Effects in Flutter: What they are and how to avoid them

Mutating state inside the build() method is very common mistake that can cause performance problems and unintended behaviour in your apps.

There are many reasons why we should avoid this, and the documentation of the build() method warns us:

This method can potentially be called in every frame and should not have any side effects beyond building a widget.

If Flutter can call the build() method every frame, we should be careful about what we put inside it.

But what is a side effect exactly?

What is a side effect?

Here's a good definition from Wikipedia):

In computer science, an operation, function or expression is said to have a side effect if it modifies some state variable value(s) outside its local environment, that is to say has an observable effect besides returning a value (the intended effect) to the invoker of the operation.

So what does this mean for us, Flutter developers?

Well, the intended effect of the build() method is to return a widget.

And the unintended side effects we must avoid at all costs are:

  • mutating state
  • executing asynchronous code

A few examples of side effects

To better illustrate the point, let's consider a few examples.

StatefulWidget with local state

Here's a simplified version of the counter app:

class _IncrementButtonState extends State<IncrementButton> { int _counter = 0; // build() should *only* return a widget. Nothing else. @override Widget build(BuildContext context) { // this *is* a side effect setState(() => _counter++); return ElevatedButton( // this is *not* a side effect onPressed: () => setState(() => _counter++), child: Text('$_counter'), ); } }

In the example above, the first call to setState() is a side effect because it modifies the local state.

But the second call to setState() is ok because it happens inside the onPressed callback, and this is only called when we tap on the button - independently from the build() method.

Let's look at some more examples.

ValueNotifier & ValueListenableBuilder

Here's a widget that uses a ValueNotifier to hold the counter state:

class IncrementButton extends StatelessWidget { const IncrementButton({required this.counter}); final ValueNotifier<int> counter; @override Widget build(BuildContext context) { // this *is* a side effect counter.value++; // use a ValueListenableBuilder to ensure that // ElevatedButton rebuilds when the counter udpates return ValueListenableBuilder( valueListenable: counter, builder: (_, value, __) => ElevatedButton( // this is *not* a side effect onPressed: () => counter.value++, child: Text('${counter.value}'), ), ); } }

This code has the unintended side effect of incrementing the counter every time the build() method is called.

And because the ValueListenableBuilder listens to the same counter, we get an unwanted widget rebuild as a result.

AnimationController

If you ever worked with explicit animations, you may have been tempted to start the animation by forwarding an AnimationController inside the build() method:

@override Widget build(BuildContext context) { // this *is* a side effect animationController.forward(); // ScaleTransition rebuilds the child // whenever the animation value changes return ScaleTransition( scale: animationController, child: Container(width: 180, height: 180, color: Colors.red), ); }

This is wrong because the AnimationController itself contains some state (the animation value) and by calling forward() we are modifying it.

Instead, it only makes sense to call forward() in initState() or inside a callback in response to some user interaction.

FutureBuilder & StreamBuilder

Here's a common use case for apps using Firebase:

// decision widget to return [HomePage] or [SignInPage] // depending on the authentication state class AuthWidget extends StatelessWidget { // injecting auth and database as constructor arguments // (these could otherwise be retrieved with Provider, Riverpod, get_it etc.) const AuthWidget({required this.auth, required this.database}); // FirebaseAuth class from the firebase_auth package final FirebaseAuth auth; // a custom FirestoreDatabase class we have defined final FirestoreDatabase database; @override Widget build(BuildContext context) { return StreamBuilder<User?>( stream: auth.authStateChanges(), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.active) { final User? user = snapshot.data; if (user == null) { return SignInPage(); } else { // intent: create a user document in Firestore // when the user first signs in // this *is** a side effect database.setUserData(UserData( uid: user.uid, email: user.email, displayName: user.displayName )); return HomePage(); } } else { return Scaffold( body: Center(child: CircularProgressIndicator()), ); } }, ); } }

The main purpose of the AuthWidget above is to return either the SignInPage or the HomePage depending on the authentication state of the user.

And a common requirement for Firebase apps is to write a "user" document to Firestore when the user first signs in.

But the call to database.setUserData() inside the StreamBuilder is a side effect.

It's also the wrong thing to do because the StreamBuilder will rebuild every time the authentication state changes. If a user signs out and signs in again with the same account, we don't want to write the same data to the database again.

A better approach would be to do this server-side and use a Cloud Function that is triggered when the user signs in and writes to Firestore if needed.

Running asynchronous code

Sometimes we need to run asynchronous code in your apps.

But the build() method (just like all other builder functions) is synchronous and returns a Widget:

Widget build(BuildContext context) { ... }

So this is not the place to put our async code. If we are stubborn and try to add an async modifier, this is what we get:

// Functions marked 'async' must have a return type assignable to 'Future' Widget build(BuildContext context) async { ... }

And since we can't use async, we can't use await either.

Though the compiler won't try to stop us if we do this:

Future<void> doSomeAsyncWork() async { ... } @override Widget build(BuildContext context) { doSomeAsyncWork(); return SomeWidget(); }

While this is not a compiler error, it may be a side effect depending on what's inside the doSomeAsyncWork() method.

There are some (rare) cases where you want to do something when the build is complete. In such cases, you can register a callback with the addPostFrameCallback() method. See this article for an in-depth explanation.

Where to run asynchronous code?

Here are a few examples where we can run asynchronous code:

Future<void> doSomeAsyncWork() async { ... } // initState @override void initState(BuildContext context) { super.initState(); // this is ok doSomeAsyncWork(); return SomeWidget(); } // button callback - example ElevatedButton( // this is ok onPressed: doSomeAsyncWork(), child: Text('${counter.value}'), ) // button callback - another example ElevatedButton( // this is ok onPressed: () async { await doSomeAsyncWork(); await doSomeOtherAsyncWork(); } child: Text('${counter.value}'), )

We can also run asynchronous code inside any event listeners, such as BlocListener from flutter_bloc or ref.listen() from Riverpod.


Conclusion: DO and DON'T

The examples above should give us a better understanding of what we can and cannot do. Once again:

The build() method can potentially be called in every frame and should not have any side effects beyond building a widget.

So here are some important rules to follow:

Do NOT modify state or call async code:

  • inside a build() method
  • inside a builder callback (e.g. MaterialPageRoute, FutureBuilder, ValueListenableBuilder etc.)
  • inside any method that returns a widget (we should define separate classes rather than methods for new widgets anyway)

If you're finding yourself doing any of these things, you're doing it wrong and you should rethink your approach.

DO modify state or call async code:

  • inside a GestureDetector or button's callback (either inline or inside a callback handler)
  • inside initState()
  • inside blocs or custom model classes that your widgets listen to
  • inside listeners (e.g. bloc listener, provider listener, animation controller listener, etc.)

This will avoid any unwanted widget rebuilds and unintended behaviour.

Happy coding!

Want more?

Fast-track your Flutter learning with over 40 hours of in-depth content.