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() {
super.initState();
// this is ok
doSomeAsyncWork();
}
// 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!