Slow apps feel cheap. The most common source of slowness in Flutter is not a complex algorithm or slow database query. It is something much simpler. Your app is probably just rebuilding parts of its UI that have not changed.
Flutter’s declarative model is powerful. You describe what the UI should look like for a given state and the framework handles the rest. But this power comes with a footgun. If you are not careful you will tell Flutter to rebuild everything on every small change. This wastes CPU cycles and causes dropped frames. The good news is that fixing this is usually easy. It just requires discipline.
The most basic way to manage state in Flutter is with a StatefulWidget
and a call to setState
. When you call setState
you are telling Flutter that some state has changed and it needs to rebuild the widget. The problem is that it does not just rebuild the one tiny part of the widget that needs to change. It rebuilds the entire widget returned by the build
method and all its children.
Imagine a screen with a header a list of items and a floating action button. If the state for a single list item is held in the main screen widget a tap on that item could trigger a setState
call that rebuilds the header the button and every other item in the list.
// A common but inefficient pattern
class BigScreen extends StatefulWidget {
@override
_BigScreenState createState() => _BigScreenState();
}
class _BigScreenState extends State<BigScreen> {
int _counter = 0;
void _increment() {
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
// This entire build method runs on every increment
// including the AppBar and other static widgets.
return Scaffold(
appBar: AppBar(title: Text(“Inefficient Rebuilds”)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('This text does not need to change:'),
Text(
'$_counter', // Only this widget needs the state
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _increment,
child: Icon(Icons.add),
),
);
}
}
In this example the AppBar
and the first Text
widget are rebuilt every time the button is pressed. This is a waste.
The simplest fix for unnecessary rebuilds is to use the const
keyword. When you declare a widget as a const
you are telling Dart that this widget and its entire subtree will never change. Flutter’s build process is smart enough to see this and it will skip rebuilding that widget entirely.
You should be aggressive with const
. Any widget that does not depend on changing state can and should be a const
. This includes things like SizedBox
Padding
Icon
and Text
with static content.
// A slightly better version
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text(“Slightly Better”)), // Const here
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// This text is now constant and will not be rebuilt
const Text('This text does not need to change:'),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _increment,
child: const Icon(Icons.add), // Const here too
),
);
}
This is a good first step. It is like free performance. But the core problem remains. The Column
and Center
widgets are still rebuilding.
The real solution is to move your state as close as possible to the widget that uses it. Instead of having one large StatefulWidget
that manages everything you should have many small StatefulWidgets
that manage their own piece of state.
Let’s refactor our counter example. We can extract the counter text into its own StatefulWidget
.
// The main screen widget can now be stateless
class BetterScreen extends StatelessWidget {
const BetterScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text(“Efficient Rebuilds”)),
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text('This text does not need to change:'),
// We've extracted the changing part
CounterText(),
],
),
),
);
}
}
// This new widget manages its own state
class CounterText extends StatefulWidget {
const CounterText({super.key});
@override
_CounterTextState createState() => _CounterTextState();
}
class _CounterTextState extends State<CounterText> {
int _counter = 0;
void _increment() {
// A timer to simulate increments
Future.delayed(const Duration(seconds: 1), () {
if (mounted) {
setState(() {
_counter++;
});
_increment(); // Loop
}
});
}
@override
void initState() {
super.initState();
_increment();
}
@override
Widget build(BuildContext context) {
// Now only this Text widget rebuilds
return Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
);
}
}
Now the main screen is a StatelessWidget
. It will be built once and never again. The CounterText
widget manages its own state. When its setState
is called only that small Text
widget is rebuilt. The AppBar
the Column
and the other Text
widget are untouched. This principle of pushing state down is the most important concept for writing performant Flutter apps.
Pushing state down into smaller widgets works well. But sometimes you need to share state between widgets or you want to avoid creating many small StatefulWidget
classes. For these cases ValueNotifier
is a simple and powerful tool that comes with Flutter.
A ValueNotifier
holds a single value. When the value changes it notifies any listeners. You can use a ValueListenableBuilder
widget to listen to a ValueNotifier
and rebuild a small part of your UI whenever the value changes.
This gives you very precise control over what gets rebuilt.
// Final version using ValueNotifier
class NotifierScreen extends StatelessWidget {
NotifierScreen({super.key});
// The state is now held in a ValueNotifier
final ValueNotifier<int> _counter = ValueNotifier<int>(0);
void _increment() {
_counter.value++;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text(“ValueNotifier Demo”)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('This text does not need to change:'),
// This builder listens to the notifier
ValueListenableBuilder<int>(
valueListenable: _counter,
builder: (context, value, child) {
// This part of the tree rebuilds when _counter changes
return Text(
'$value',
style: Theme.of(context).textTheme.headline4,
);
},
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _increment,
child: const Icon(Icons.add),
),
);
}
}
Here the entire screen is a StatelessWidget
. The state is held in the _counter
notifier. The ValueListenableBuilder
subscribes to this notifier and its builder
function is the only thing that runs again when the value changes. This is often the cleanest and most performant solution for simple local state.
For more complex applications where you need to share state across many screens you might reach for a package like Provider or Riverpod. But they are built on the same underlying principles. If you understand how to use const
push state down and use ValueNotifier
you already know everything you need to keep your app fast.
Think about the parts of your own UI that change. Are you rebuilding the whole screen for a small update? Take a look at your own app and see where the rebuilds are happening. Why not try answering this for yourself.
— Rishi Banerjee
September 2025