Summary
There are two common ways to build screens. Imperative code tells the computer exactly what to do, step by step. Declarative code describes what the screen should look like for the current state, and the framework updates it for you. Flutter encourages the declarative way. This post keeps things simple, shows tiny examples, and gives a clear path to try the ideas on your own device.
A calm starting point
Imagine a small screen that shows a list of books. There is a loading spinner, and sometimes an error message. On a busy day the old list stays visible under the error card. You fix one path, another breaks. It feels fragile. A change of style can help.
Two ways to think
Imperative: change what already exists. Show the spinner, hide the list, replace the text.
Declarative: say what the
screen
should be for this state. If loading, return a spinner. If ready, return a list.
An everyday picture helps. If a friend asks for directions to your flat:
Imperative sounds like this. Turn left at the bakery, walk past the park, take the third right, press the second buzzer.
Declarative sounds like this. Number 18, Willow Road, the blue door.
One is a list of steps. The other is the intended result.
A small imperative example
This short example is from the classic Android view system. You flip visibility by hand and push data into the list.
Kotlin
// Small, imperative example in Kotlin
sealed interface ScreenState
object Loading : ScreenState
data class Ready(val items: List<String>) : ScreenState
fun render(
state: ScreenState,
spinner: View,
list: RecyclerView,
adapter: ListAdapter<String, *>
) {
spinner.visibility = if (state is Loading) View.VISIBLE else View.GONE
list.visibility = if (state is Ready) View.VISIBLE else View.GONE
if (state is Ready) {
adapter.submitList(state.items)
}
}
It is clear when there are only two states. It becomes easy to miss a line when more states appear. That is when yesterday’s content sometimes remains visible. That approach works, but it does not scale well. Let us now see how Flutter encourages a different style.
A small declarative example with setState
Now the same idea with Flutter. We describe the whole screen from the current state. There is a single source of truth, the screen
value.
How to try this now
- "Create a new Flutter project."
- "Replace
lib/main.dart
with the code below." - "Run the application and tap the button."
import 'package:flutter/material.dart';
// Two simple states for the screen
enum Screen { loading, ready }
// A tiny value object for books
class Book {
final String title;
final String author;
const Book(this.title, this.author);
}
void main() => runApp(const MaterialApp(home: BooksDemo()));
class BooksDemo extends StatefulWidget {
const BooksDemo({super.key});
@override
State<BooksDemo> createState() => _BooksDemoState();
}
class _BooksDemoState extends State<BooksDemo> {
Screen screen = Screen.loading;
List<Book> items = const [];
Future<void> load() async {
setState(() => screen = Screen.loading);
await Future<void>.delayed(const Duration(seconds: 1));
setState(() {
items = const [
Book('Practical Flutter', 'Sara Conte'),
Book('Patterns for Mobile', 'Leo Ahmed'),
];
screen = Screen.ready;
});
}
@override
void initState() {
super.initState();
load(); // start loading as soon as the screen appears
}
@override
Widget build(BuildContext context) {
// Describe the whole screen from the current state
if (screen == Screen.loading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
// Ready state
return Scaffold(
appBar: AppBar(title: const Text('Books')),
body: ListView.builder(
itemCount: items.length,
itemBuilder: (_, i) => ListTile(
title: Text(items[i].title),
subtitle: Text('By ${items[i].author}'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: load,
child: const Icon(Icons.refresh),
),
);
}
}
What to notice
The first thing to notice is that there is no manual show or hide. You are not juggling visibility flags or remembering what was visible before. Instead, you simply describe the tree as it should look right now, and Flutter handles the rebuilding for you.
Another detail is the absence of leftover state. Because you always return the correct tree for the current value, yesterday’s content does not accidentally leak into today’s frame.
You might also see that this approach reduces side effects. You are not nudging old widgets into place, you are just returning the correct shape for the moment.
And finally, it becomes easier to test. You can say: given this state, I expect a spinner. Or: given that state, I expect this text or a certain number of tiles. That clarity is a direct payoff of the declarative style. The key point is that you focus on describing the state, not on hiding or showing parts by hand. With that in mind, let us try a small example where the user taps a switch.
A very gentle user input example
Here is a tiny settings screen. It has a dark mode switch. When the switch changes, the app theme changes. No manual updates to labels. No hidden toggles.
import 'package:flutter/material.dart';
void main() => runApp(const ThemeDemo());
class ThemeDemo extends StatefulWidget {
const ThemeDemo({super.key});
@override
State<ThemeDemo> createState() => _ThemeDemoState();
}
class _ThemeDemoState extends State<ThemeDemo> {
bool dark = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
themeMode: dark ? ThemeMode.dark : ThemeMode.light,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListTile(
title: const Text('Dark mode'),
trailing: Switch(
value: dark,
onChanged: (v) => setState(() => dark = v),
),
),
),
);
}
}
The widget tree is a clear reflection of state. If dark
is true, the dark theme is used. If it is false, the light theme is used.
A simple form that reacts to state
This shows how a small form can feel declarative. The message appears when the name is too short. There is no manual show or hide scattered around.
class NameForm extends StatefulWidget {
const NameForm({super.key});
@override
State<NameForm> createState() => _NameFormState();
}
class _NameFormState extends State<NameForm> {
String name = '';
@override
Widget build(BuildContext context) {
final isValid = name.trim().length >= 3;
return Padding(
padding: const EdgeInsets.all(16),
child: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(
decoration: const InputDecoration(labelText: 'Your name'),
onChanged: (v) => setState(() => name = v),
),
const SizedBox(height: 8),
if (!isValid)
const Text('Please enter at least three characters',
style: TextStyle(color: Colors.red)),
const SizedBox(height: 8),
ElevatedButton(
onPressed: isValid ? () {} : null,
child: const Text('Continue'),
),
]),
);
}
}
The button enables itself when the state is valid. The error text appears only when needed. Clear and predictable.
Key differences you can feel
Readability: Declarative code reads like a small list of states. Loading, ready, failed. The intent is visible.
Single source of truth: Keep a single state value. The tree is built from that value. Old values do not leak into new frames.
Fewer side effects: You are not nudging old widgets into shape. You are returning the correct shape for now.
Easier testing: You can say, given this state, I expect a spinner, or this text, or a certain number of tiles.
When to choose which
Small, local screens. Use the setState function. It is simple and fine.
Shared or cross screen state. Move state into a small model with Provider or Riverpod.
Many transitions and tricky flows. Consider Bloc for explicit events and clearer steps.
If you are unsure, begin with the setState function. When the widget starts to feel busy, lift state into a model. You will feel the difference.
A gentle way to migrate an old screen
Write down the states. Loading, empty, ready, failed.
Create one state value and a single
if
orswitch
that returns the right body.Extract small helper widgets for each branch.
Remove old show or hide code one piece at a time.
Add an explicit empty state if the list can be empty. That small decision prevents confusion later.
This is steady work. It pays off quickly.
Takeaways you can use today
Describe the screen from state rather than patching it.
Keep a single source of truth.
Start small with the setState function. Lift state into a model when needed.
Name states up front. It saves time and reduces accidental drift.
Flutter’s declarative style can feel unusual at first, but once you start, you will likely find your code easier to read, test, and grow. Start small, keep a single source of truth, and let the framework do the heavy lifting.
References:
- Introduction to declarative UI – Flutter Docs
- State management fundamentals – Flutter Docs
- Start thinking declaratively – Flutter Docs
- Bloc State Management Library
- Flutter – a modern declarative UI toolkit
- Declarative UI vs. Imperative UI in Flutter – DhiWise
- Flutter Design Patterns and Best Practices – Packt
Top comments (0)