Sitemap

Digging into Flutter’s setState

6 min readAug 3, 2026

--

Press enter or click to view image in full size

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 element as dirty and 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:

  1. Creating a new frame is handled by handleBeginFrame and handleDrawFrame, which are invoked by the Flutter engine.
  2. handleDrawFrame calls drawFrame, and this is where the elements previously marked as dirty get 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:

  1. TransientCallbacks: callbacks that run at the start of a frame, for animations or things that change over time
  2. PersistentCallbacks: callbacks that must run on every frame (rendering, layout work, etc.)
  3. 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:

  1. After setState() runs, StatefulElement.markNeedsBuild() is called
  2. This method calls BuildOwner.scheduleBuildFor(element), adding the Element to the dirtyElements list
  3. When the next frame begins, SchedulerBinding.handleDrawFrame() is called, executing the registered PersistentCallbacks
  4. Among the registered PersistentCallbacks is _handlePersistentFrameCallback(), which runs the WidgetsBinding.drawFrame() method — ultimately asking the BuildOwner for a rebuild starting from the root Element (rootElement)
  5. The BuildOwner rebuilds all Elements marked as dirty, in order, via flushDirtyElements()

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 as dirty are rebuilt on a per-frame basis. If you overuse setState() or apply setState() on a large element, 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:

  1. SchedulerBinding.handleBeginFrame() executes the transient callbacks
  2. SchedulerBinding.handleDrawFrame() executes the persistent callbacks, during which drawFrame() runs
  3. SchedulerBinding.handleDrawFrame() executes the post 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.

--

--

dev_bbong
dev_bbong

Written by dev_bbong

Flutterian, gonna be Google Developer Expert