Flutter's setState() might not be what you think it is
Here’s a somewhat embarrassing assumption I made about setState
when I started to learn Flutter
almost 4 years ago.
We all know setState
from the counter example:
class _MyWidgetState extends State<MyWidget> {
int _counter = 0;
void _incrementCounter() {
setState(() {
_counter = _counter + 1;
});
}
@override
Widget build(BuildContext context) {
return Text("The current value is $_counter!");
}
}
Here’s how I thought it worked:
- There’s one stateful field called
_counter
. - We also have a
Text
widget that displays the value of_counter
. - Every time we want to update
_counter
, we also have to wrap it in an anonymous function and pass it tosetState
. Otherwise, the framework doesn’t know what was updated.
Four months into my Flutter journey, I found out that assumption number three was not true.
We do have to call setState
when updating _counter
, but there’s absolutely no need to do it in an anonymous callback.
This:
setState(() {
_counter = _counter + 1;
});
is exactly the same as this:
_counter = _counter + 1;
setState(() {});
The Flutter framework does not magically inspect code inside the anonymous function and then do some diffing to see what changed.
Whenever you call setState
, the widget rebuilds - even if nothing changed and the callback passed to setState
was empty.
What I had initially assumed about setState
was totally made up in my own head.
A peek behind the curtains
Let’s see how the code behind setState
looks like:
@protected
void setState(VoidCallback fn) {
assert(fn != null);
assert(() {
if (_debugLifecycleState == _StateLifecycle.defunct) {
throw FlutterError.fromParts([/* ... */]);
}
if (_debugLifecycleState == _StateLifecycle.created && !mounted) {
throw FlutterError.fromParts([/* ... */]);
}
return true;
}());
final dynamic result = fn() as dynamic;
assert(() {
if (result is Future) {
throw FlutterError.fromParts([/* ... */]);
}
return true;
}());
_element.markNeedsBuild();
}
Let’s remove the assertions:
@protected
void setState(VoidCallback fn) {
final dynamic result = fn() as dynamic;
_element.markNeedsBuild();
}
We can actually boil it down even more:
@protected
void setState(VoidCallback fn) {
fn();
_element.markNeedsBuild();
}
All that setState
does is calling the provided callback and then the associated element gets scheduled for a new build.
No magical field diffing here.
So why do we have setState then?
Because of UX studies ran by the Flutter team.
They do a lot of it.
Back in 2017 and 2018, there was a GitHub issue label called “first hour”. The Flutter team ran developer UX studies and observed how Flutter newcomers would use Flutter when left on their own for an hour. Those studies would shape future API decisions for Flutter.
Apparently, the setState
thing is one of these findings.
From the GitHub issue that made me realize I had been living in a lie:
We used to just have a markNeedsBuild method but we found people called it like a good luck charm – any time they weren’t sure if they needed to call it, they’d call it.
We changed to a method that takes a (synchronously-invoked) callback, and suddenly people had much less trouble with it.
There’s also a StackOverflow answer from Collin Jackson from the Flutter team back then:
When Flutter had a “markNeedsBuild” function, developers ended up just sort of calling it at random times. When the syntax switched to setState((){ }), developers were much more likely to use the API correctly.
So the whole setState
API is just a mental trick.
And it seems to work - apparently it lead into people building their widgets less often.
I never had problems knowing when to call setState
, but I can definitely see myself wondering “who’s Mark and what
is he building?”
Mystery solved.
Conclusion
What does all of this mean?
Should we now convert all our setState
calls to something like this?
_counter = _counter + 1;
setState(() {});
Probably not.
Even though there’s no difference, calling setState
with an anonymous callback feels right.
At this point, the anonymous callback is an established convention and anything else just feels weird.
9 comments
likes