Digging into Flutter’s setState
Flutter’s most basic state management tools are probably the State class and the setState() method.
I'd been using them without giving it much thought, but at some point I got curious: when I trigger a state change with setState(), what actually happens under the hood to refresh the screen?
In this post, we'll walk through Flutter's source code step by step, from the moment setState() is called to the moment the screen is actually rebuilt.
Some code has been omitted to focus on the core flow. If you want to see the full source code, I recommend checking out the official Flutter repository directly.
1. What happens when you call setState()
Our starting point is the State class and the implementation of the setState() method.
In Flutter, the setState() method is declared at the following path:
- File path:
packages/flutter/lib/src/widgets/framework.dart
abstract class State<T extends StatefulWidget> with Diagnosticable {
T get widget => _widget!;
T? _widget;
StatefulElement? _element;
@protected
void setState(VoidCallback fn) {
final Object? result = fn() as dynamic;
_element!.markNeedsBuild();
}
}As you can see, setState() runs the callback (fn) it receives and then calls markNeedsBuild() on the connected StatefulElement.
That callback is whatever we write inside setState() while building our apps.
Example:
IconButton(
icon: Icon(_isCalendarView ? Icons.list : Icons.calendar_month),
onPressed: () {
setState(() {
_isCalendarView = !_isCalendarView;
});
},
),As the name suggests, markNeedsBuild() is "a method that marks this element as needing to be rebuilt." The method's doc comment makes this even clearer:
Marks the
elementasdirtyand adds it to the global list of widgets to rebuild in the next frame.
So now let’s take a closer look at the markNeedsBuild() method.
markNeedsBuild() is a method implemented on StatefulElement.
- File path:
packages/flutter/lib/src/widgets/framework.dart
abstract class StatefulElement... {
bool _dirty = true;
BuildOwner owner;
void markNeedsBuild() {
if (dirty) return;
_dirty = true;
owner!.scheduleBuildFor(this);
}
}The key part here is that it hands this element (via this) over to the BuildOwner to schedule a build. The doc comment confirms it:
Adds an element to the dirty elements list so that it will be rebuilt when [WidgetsBinding.drawFrame] calls [buildScope].
So far, then: setState() marks the element as dirty and asks the BuildOwner to schedule a build for it.
Next, let’s find out how the build actually happens for the dirty element list — the collection of elements marked dirty.
2. drawFrame: How does the rebuild actually happen?
Now let’s look at WidgetsBinding.drawFrame(), where the screen actually gets rebuilt. To summarize the role of drawFrame() in the WidgetsBinding class, based on the comments in the original source:
- Creating a new frame is handled by
handleBeginFrameandhandleDrawFrame, which are invoked by the Flutter engine. handleDrawFramecallsdrawFrame, and this is where theelements previously marked asdirtyget rebuilt.
Let’s look at the actual implementation.
- File path:
lib/src/widgets/binding.dart
@override
void drawFrame() {
assert(!debugBuildingDirtyElements);
try {
if (rootElement != null) {
buildOwner!.buildScope(rootElement!);
}
super.drawFrame();
buildOwner!.finalizeTree();
}
}What matters here is that buildOwner.buildScope(rootElement) is called.
- File path:
lib/src/widgets/framework.dart
void buildScope(Element context, [VoidCallback? callback]) {
final BuildScope buildScope = context.buildScope;
if (callback == null && buildScope._dirtyElements.isEmpty) return;
try {
_scheduledFlushDirtyElements = true;
buildScope._building = true;
buildScope._flushDirtyElements(debugBuildRoot: context);
}
}It calls flushDirtyElements to run the build for the dirtyElements.
This is how the elements actually marked as dirty get rebuilt.
Notice the conditional at the top: if there are no dirtyElements, it simply returns. This is an efficiency win — if there's nothing that actually needs building, nothing gets built.
Let’s also check flushDirtyElements:
- File path:
lib/src/widgets/framework.dart
void _flushDirtyElements({required Element debugBuildRoot}) {
_dirtyElements.sort(Element._sort);
try {
for (int index = 0; index < _dirtyElements.length; index++) {
final Element element = _dirtyElements[index];
if (identical(element.buildScope, this)) {
_tryRebuild(element);
}
}
} finally {
for (final Element element in _dirtyElements) {
if (identical(element.buildScope, this)) {
element._inDirtyList = false;
}
}
_dirtyElements.clear();
}
}In the end, it iterates over the entire dirtyElements list and rebuilds each element via tryRebuild(element).
3. What is a frame?
We’ve already run into terms like “creating a new frame” and drawFrame.
Clearly, Flutter draws the screen in units called frames — so let’s make sure we understand what a frame is before moving on.
A Flutter app runs at 60 frames per second (fps) by default. In other words, the screen can be refreshed up to 60 times per second.
The Flutter engine uses this to operate a frame-based rendering loop.
That is, 60 times a second, it decides whether or not to render the screen anew — and as we saw earlier, if there are dirty elements, the elements are rebuilt in that frame.
Get dev_bbong’s stories in your inbox
Join Medium for free to get updates from this writer.
The core functions of this rendering loop are handleBeginFrame and handleDrawFrame.
- File path:
lib/src/scheduler/binding.dart
void handleBeginFrame(Duration? rawTimeStamp) {
try {
// TRANSIENT FRAME CALLBACKS
_schedulerPhase = SchedulerPhase.transientCallbacks;
final Map<int, _FrameCallbackEntry> callbacks = _transientCallbacks;
_transientCallbacks = <int, _FrameCallbackEntry>{};
callbacks.forEach((int id, _FrameCallbackEntry callbackEntry) {
if (!_removedIds.contains(id)) {
_invokeFrameCallback(
callbackEntry.callback,
_currentFrameTimeStamp!,
callbackEntry.debugStack,
);
}
});
_removedIds.clear();
} finally {
_schedulerPhase = SchedulerPhase.midFrameMicrotasks;
}
}The role of handleBeginFrame() is to execute all the transient callbacks.
There are three kinds of callbacks that run during frame production:
TransientCallbacks: callbacks that run at the start of a frame, for animations or things that change over timePersistentCallbacks: callbacks that must run on every frame (rendering, layout work, etc.)PostFrameCallbacks: callbacks that run after the frame has finished
Of these, handleBeginFrame executes the TransientCallbacks.
Next up is handleDrawFrame.
- File path:
lib/src/scheduler/binding.dart
void handleDrawFrame() {
assert(_schedulerPhase == SchedulerPhase.midFrameMicrotasks);
_frameTimelineTask?.finish(); // end the "Animate" phase
try {
// PERSISTENT FRAME CALLBACKS
_schedulerPhase = SchedulerPhase.persistentCallbacks;
// Execute all persistent callbacks
for (final FrameCallback callback in List<FrameCallback>.of(_persistentCallbacks)) {
_invokeFrameCallback(callback, _currentFrameTimeStamp!);
}
// POST-FRAME CALLBACKS
_schedulerPhase = SchedulerPhase.postFrameCallbacks;
final List<FrameCallback> localPostFrameCallbacks = List<FrameCallback>.of(
_postFrameCallbacks,
);
_postFrameCallbacks.clear();
if (!kReleaseMode) {
FlutterTimeline.startSync('POST_FRAME');
}
try {
// Execute all post callbacks
for (final FrameCallback callback in localPostFrameCallbacks) {
_invokeFrameCallback(callback, _currentFrameTimeStamp!);
}
} finally {
if (!kReleaseMode) {
FlutterTimeline.finishSync();
}
}
}
}This one, in turn, executes the registered PersistentCallbacks and PostFrameCallbacks.
That covers the frame-production code. So in which of these phases does drawFrame, the thing that redraws the dirty elements, actually run?
The short answer: drawFrame is registered as one of the PersistentCallbacks.
Take a closer look at the file where drawFrame was declared:
- File path:
lib/src/rendering/binding.dart
mixin RendererBinding {
@override
void initInstances() {
super.initInstances();
_instance = this;
_rootPipelineOwner = createRootPipelineOwner();
addPersistentFrameCallback(_handlePersistentFrameCallback); // registers _handlePersistentFrameCallback
}
void _handlePersistentFrameCallback(Duration timeStamp) {
drawFrame(); // a familiar face: drawFrame()
_scheduleMouseTrackerUpdate();
}So drawFrame is executed inside _handlePersistentFrameCallback, which is registered via addPersistentFrameCallback and ultimately runs inside the handleDrawFrame we just examined.
Since the Flutter engine invokes handleBeginFrame and handleDrawFrame on every frame, the net effect is that 60 times a second, there's an opportunity to rebuild the elements whose state has changed and reflect them on screen.
4. Putting it all together
When you call setState(), the rebuild proceeds in the following order:
- After
setState()runs,StatefulElement.markNeedsBuild()is called - This method calls
BuildOwner.scheduleBuildFor(element), adding the Element to the dirtyElements list - When the next frame begins,
SchedulerBinding.handleDrawFrame()is called, executing the registeredPersistentCallbacks - Among the registered
PersistentCallbacksis_handlePersistentFrameCallback(), which runs theWidgetsBinding.drawFrame()method — ultimately asking theBuildOwnerfor a rebuild starting from the root Element (rootElement) - The
BuildOwnerrebuilds allElements marked asdirty, in order, viaflushDirtyElements()
This is how all the changes resulting from state updates get reflected, one frame at a time.
If all you do is call setState(), none of this may seem to matter much — but there are some important takeaways:
elements marked asdirtyare rebuilt on a per-frame basis. If you overusesetState()or applysetState()on a largeelement, there's more work to do in that frame, which can cause performance degradation.
We all vaguely knew that StatefulWidget and setState() are heavier on performance than StatelessWidget.
Now that we've seen that state changes are processed frame by frame and that every element marked dirty gets rebuilt, it's easy to appreciate how carelessly written StatefulWidgets and setState() calls can take a real toll on performance.
To use StatefulWidget and setState() well, it's best to split your widgets into the smallest possible units.
The smaller the widget — the element — that gets marked as dirty, the fewer elements need to be rebuilt in the next frame.
Understanding how the Flutter engine draws the screen also gives us a picture of the widget lifecycle flow:
SchedulerBinding.handleBeginFrame()executes thetransient callbacksSchedulerBinding.handleDrawFrame()executes thepersistent callbacks, during which drawFrame() runsSchedulerBinding.handleDrawFrame()executes thepost callbacks
Ultimately, the part where widgets are actually drawn is the persistent callbacks execution phase, where drawFrame() runs.
What if you want to run some code after the widgets have finished drawing? There are various approaches, but one is to register that work as a post callback:
WidgetsBinding.instance!.addPostFrameCallback((_) {
doSomething();
})This way, the code gets registered as a post callback and will run after drawFrame().
5. Closing
That wraps up our look at how setState() — something we use so casually and comfortably — works under the hood, and how the Flutter engine composes frames.






