Most Flutter apps start the same way. A few simple StatefulWidgets. You call setState
and everything works. It feels fast and direct. But as the app grows you notice something is wrong. Your build methods become bloated with business logic. State is passed down through five layers of widgets. Changing one small thing feels risky because you don’t know what it will break.
This is a common path. The tools that make Flutter so easy to start with don’t automatically scale to a complex application. The problem is not Flutter. It’s that we mix our concerns. We put business logic state management and UI declaration all in one place the widget tree.
There is a simple pattern that fixes this. It doesn’t require learning a complex new state management library. It uses tools already built into Flutter. The pattern is to separate your logic into services that expose their state through ValueNotifier
.
Let’s look at a typical example. You have a screen that shows a user’s profile. You might start with a StatefulWidget that does everything.
// A common but messy approach
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key, required this.userId});
final String userId;
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
bool _isLoading = true;
User? _user;
String? _error;
@override
void initState() {
super.initState();
_fetchUser();
}
Future<void> _fetchUser() async {
try {
// Imagine this is a real API call
final user = await ApiClient.fetchUser(widget.userId);
setState(() {
_user = user;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'Failed to load user';
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(child: Text(_error!));
}
return Center(child: Text('Hello, ${_user!.name}'));
}
}
This widget is doing too much. It’s fetching data. It’s managing loading error and success states. It’s rendering the UI. If you want to reuse the user fetching logic somewhere else you have to copy and paste it. If you want to test the logic you have to build the whole widget. This approach is a dead end.
The fix is to pull the logic out. We can create a simple Dart class called a service that handles fetching and holding the profile data.
This class will not know anything about Flutter or widgets. It’s just pure Dart. This makes it easy to test and reason about. The service then exposes its state to the UI layer using ValueNotifier
.
Here is what that service looks like:
// A service to handle business logic
import 'package:flutter/foundation.dart';
// Your User model
class User {
final String id;
final String name;
User({required this.id, required this.name});
}
class ProfileService {
// The state is exposed via ValueNotifiers
final ValueNotifier<User?> user = ValueNotifier(null);
final ValueNotifier<bool> isLoading = ValueNotifier(false);
final ValueNotifier<String?> error = ValueNotifier(null);
// Business logic lives here
Future<void> fetchUser(String userId) async {
isLoading.value = true;
error.value = null;
try {
// This is where you call your real API client
// We’ll simulate a network call
await Future.delayed(const Duration(seconds: 1));
user.value = User(id: userId, name: 'Jane Doe');
} catch (e) {
error.value = 'Failed to load user';
}
isLoading.value = false;
}
}
Look how clean that is. The logic for fetching a user is in one place. The state user
isLoading
and error
is managed internally but exposed to the outside world through simple notifiers.
Now our widget becomes much simpler. Its only job is to listen to the service and build the UI based on the state. We don’t need a StatefulWidget anymore. A StatelessWidget is enough. We use the ValueListenableBuilder
widget to listen for changes on a ValueNotifier
and rebuild a part of our UI when the value changes.
// The UI widget now only builds the UI
class ProfileScreen extends StatelessWidget {
const ProfileScreen({super.key, required this.service});
final ProfileService service;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Center(
// Listen to multiple notifiers
child: ValueListenableBuilder<bool>(
valueListenable: service.isLoading,
builder: (context, loading, _) {
if (loading) {
return const CircularProgressIndicator();
}
return ValueListenableBuilder<String?>(
valueListenable: service.error,
builder: (context, err, _) {
if (err != null) {
return Text(err);
}
return ValueListenableBuilder<User?>(
valueListenable: service.user,
builder: (context, usr, _) {
if (usr == null) return const Text('No user data');
return Text('Hello, ${usr.name}');
},
);
},
);
},
),
),
);
}
}
This approach is a huge improvement. The widget is now declarative. It describes the UI for a given state but doesn’t contain the logic to produce that state. This separation makes your code easier to read test and reuse.
It is also more performant. When you call setState
the entire widget’s build
method runs. With ValueListenableBuilder
only the widget inside the builder function rebuilds when the value changes. This is the same principle described in our post on how to stop useless widget rebuilds.
You will need a way to provide the service to the widget. For a small app you can just create an instance and pass it in the constructor. For a larger app you might use a package like
provider
or a service locator likeget_it
. But the pattern of separating logic into services remains the same.
The next time you find yourself writing a complex StatefulWidget stop and think. Could this logic be pulled out into a simple service? This small change in approach will prevent your UI code from becoming a tangled mess.
Think about the messiest part of your current Flutter app.
— Rishi Banerjee
September 2025