Best Practice #
Choosing the right state management library for your project is only half the journey. The other half, equally crucial, is how you apply it in day-to-day code architecture. Many developers build apps using very good libraries like Riverpod or Bloc, but in the wrong way — for example, complex business logic still written inside UI widgets, state mutations done directly from anywhere, or code written in a way that can’t be tested with unit tests.
Poor state management implementation will create a mountain of technical debt that gradually makes the app hard to develop, unstable, and prone to bugs. This document summarizes a collection of state management architecture best practices that apply across Flutter libraries. By applying these principles, you can build clean, modular, easy-to-test code that’s friendly for team collaboration.
Clean & Layered App Architecture #
Before discussing specific code writing rules, we need to agree on the foundation of a healthy app architecture. Modern architecture design always divides the app into isolated layers with clear responsibilities (separation of concerns).
Here’s a layered architecture diagram illustrating how the UI, state controllers, and data layer communicate regularly:
graph TD
classDef default stroke:#333,stroke-width:2px;
subgraph UI_Layer["Interface Layer (UI Layer)"]
A["Widget / Screen Page"]
end
subgraph Logic_Layer["Business Logic Layer (State Management Layer)"]
B["Notifier / Bloc / Store (ViewModel)"]
C["State (Immutable Data Class)"]
end
subgraph Domain_Layer["Domain & Data Layer (Repository Layer)"]
D["Repository Interface / Implementation"]
E["Network API Client (Dio / Http)"]
F["Local Database (Hive / Drift / SharePref)"]
end
A -->|1. Send Action / Interaction| B
B -->|2. Request Data| D
D -->|3. Network Fetch| E
D -->|4. Local Query| F
E -. "5. Return Raw Data" .-> D
F -. "6. Return Local Data" .-> D
D -->|7. Return Model / Entity| B
B -->|8. Emit New State| C
C -. "9. Trigger Granular Rebuild" .-> AIn this architecture, data and control flow move in one direction. UI widgets send events or trigger methods on state controllers (Notifier/Bloc/Store). State controllers then interact with the repository layer to fetch or change data. The results are then emitted back as new state that triggers the interface to redraw (rebuild) itself precisely.
1. Separating UI from Business Logic (Separation of Concerns) #
The most fundamental principle you must enforce is: UI widgets are only responsible for visual rendering. Widgets shouldn’t know how to validate email addresses, calculate prices after discounts, or format parameters for sending to a server. Those logics belong entirely to the state controller layer.
Let’s compare the code quality difference in the following checkout process scenario:
Anti-Pattern: Writing Business Logic in UI Widgets #
Writing calculations, validations, and I/O flows directly inside button functions or StatefulWidget classes makes code hard to read and impossible to test separately.
// ANTI-PATTERN: Business logic and data mixed inside the widget button
class CheckoutPage extends StatefulWidget {
const CheckoutPage({super.key});
@override
State<CheckoutPage> createState() => _CheckoutPageState();
}
class _CheckoutPageState extends State<CheckoutPage> {
bool _isLoading = false;
Future<void> _payNow() async {
// 1. Validate the local shopping cart
if (localCart.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Shopping cart is empty!')),
);
return;
}
// 2. Calculate price and tax in the UI
final subtotal = localCart.fold(0.0, (sum, item) => sum + item.price);
final tax = subtotal * 0.11;
final total = subtotal + tax;
if (total > userBalance) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Your balance is insufficient!')),
);
return;
}
setState(() => _isLoading = true);
try {
// 3. Direct API call in the widget
final orderResponse = await http.post(
Uri.parse('https://api.store.com/orders'),
body: {'total': total.toString(), 'items': localCart.map((e) => e.id).toList()},
);
if (orderResponse.statusCode == 200) {
// Direct UI navigation
Navigator.push(context, MaterialPageRoute(builder: (_) => const SuccessScreen()));
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: _isLoading ? null : _payNow,
child: _isLoading ? const CircularProgressIndicator() : const Text('Pay'),
);
}
}
Clean Pattern: Widgets Only Orchestrate Visuals #
Widgets only observe state and trigger actions. Calculation and I/O logic moves to the Notifier.
// CORRECT: The widget only deals with UI rendering
class CleanPayButton extends ConsumerWidget {
const CleanPayButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final checkoutState = ref.watch(checkoutNotifierProvider);
return ElevatedButton(
onPressed: checkoutState.isLoading
? null
: () => ref.read(checkoutNotifierProvider.notifier).makePayment(),
child: checkoutState.isLoading
? const CircularProgressIndicator()
: const Text('Pay Now'),
);
}
}
// Business logic lives entirely in the Notifier (State Controller)
class CheckoutNotifier extends AutoDisposeAsyncNotifier<CheckoutState> {
@override
Future<CheckoutState> build() async => const CheckoutState.initial();
Future<void> makePayment() async {
final cart = ref.read(cartNotifierProvider);
state = const AsyncLoading();
try {
// 1. Validation in the business layer
if (cart.isEmpty) throw Exception('Shopping cart is empty!');
// 2. Isolated calculation
final total = _calculateTotal(cart);
// 3. Delegate data calls to the Repository
final order = await ref.read(orderRepositoryProvider).createOrder(cart, total);
await ref.read(paymentRepositoryProvider).processPayment(order.id);
state = AsyncData(CheckoutState.success(order));
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
double _calculateTotal(List<Item> items) {
final subtotal = items.fold(0.0, (sum, item) => sum + item.price);
return subtotal + (subtotal * 0.11);
}
}
2. Single Source of Truth #
In complex apps, the same data is often needed by several different screens. The Single Source of Truth principle asserts that every app data should only be stored and managed in one place. Duplicating data storage across several controllers or local widgets is the main cause of hard-to-debug “out-of-sync data” bugs.
A Sync Error Case: #
Suppose you copy the username data from global state into a local variable of a profile edit screen:
// ANTI-PATTERN: Copying global data into local widget state
class _EditProfilePageState extends State<EditProfilePage> {
late String _localName;
@override
void initState() {
super.initState();
// Copying the data. Now there are two storage places for the same data!
_localName = context.read<AuthNotifier>().user.name;
}
// If the name is changed on the server by another part of the app (e.g., background update),
// this local widget will never know because it maintains its own copy.
}
The Correct Solution Pattern: #
Always direct widgets to read directly from one global source of truth using selection mechanisms (selector or select) so widgets only rebuild when the specific property they read changes.
// CORRECT: Reading in real-time from a single source
class ProfileNameDisplay extends ConsumerWidget {
const ProfileNameDisplay({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Reading directly and specifically using 'select'
final userName = ref.watch(authNotifierProvider.select((s) => s.user.name));
return Text(userName);
}
}
3. Immutable State #
Immutable state means the state container class properties cannot be changed directly after initialization. State modification can only be done by creating a new instance of the class using a copy of the old data (using the copyWith method).
Why Is Immutability So Important? #
- Protection from Accidental Mutation: Prevents silent data changes by widgets outside the business logic layer.
- Efficient State Comparison: Flutter can compare state changes very quickly just by checking memory references (
oldState != newState). If the memory reference differs, Flutter knows the state has changed and the UI must rebuild. - Debugging Ease: Enables state change history logging (time-travel debugging) because every change produces a unique state snapshot object in memory.
// CORRECT: Defining the state class immutably
@immutable
class ProductListState {
final List<Product> products;
final bool isLoading;
final String? errorMessage;
const ProductListState({
this.products = const [],
this.isLoading = false,
this.errorMessage,
});
// Required method for safely modifying state
ProductListState copyWith({
List<Product>? products,
bool? isLoading,
String? errorMessage,
}) {
return ProductListState(
// If the parameter is null, use the old value
products: products ?? this.products,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}
When updating state inside the Notifier, always emit a new instance:
// Updating loading
state = state.copyWith(isLoading: true);
// Updating data after a successful API fetch
state = state.copyWith(
isLoading: false,
products: apiResultList,
);
4. Designing Granular State #
Avoid creating one giant state class holding all your app’s information (often called the God State). If you store theme configuration, shopping cart data, authentication status, and product lists in the same state class, then every time a new item enters the shopping cart, all app widgets displaying profile names or settings menus will undergo unnecessary rebuilds.
Solution: Split State by Domain #
Divide your state into small, self-contained (granular) modules based on functionality:
AuthState: Specifically handles user login data, tokens, and access rights.CartState: Specifically manages the shopping list in the cart.ThemeState: Specifically stores the app’s dark/light mode preference.ProductListState: Specifically manages product lists and search filters.
Each widget may only subscribe to the state domain it needs to draw itself:
// The cart icon widget only subscribes to CartState
final itemCount = ref.watch(cartNotifierProvider.select((s) => s.items.length));
5. Handling All Async States Explicitly #
Data coming from the internet is always asynchronous and uncertain. There are three possible conditions you must always handle visually in the UI:
- Loading: Display a data loading indicator so users know the system is processing the request.
- Error: Display a clear error message along with a retry button if the connection fails.
- Data: Display the actual data results, including handling the special case where the data is empty (empty state).
Failure to handle any of these three conditions can make your app look frozen, show a blank white screen, or even crash on user devices.
If you’re using Riverpod, take advantage of the AsyncValue class which forces you to handle all three conditions at the compile level:
class ProductListWidget extends ConsumerWidget {
const ProductListWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncProducts = ref.watch(productsProvider);
return asyncProducts.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stackTrace) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Failed to load data: $error'),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref.invalidate(productsProvider),
child: const Text('Retry'),
),
],
),
),
data: (productList) {
if (productList.isEmpty) {
return const Center(child: Text('No products available.'));
}
return ListView.builder(
itemCount: productList.length,
itemBuilder: (context, index) => ListTile(title: Text(productList[index].name)),
);
},
);
}
}
6. Validating Data in the Business Logic Layer #
Many developers write form validation logic (e.g., email format verification, minimum password length, or password confirmation matching) directly inside UI button event callbacks. This is a bad habit because such validation can’t be tested independently through automated unit tests.
The UI should only be responsible for collecting raw strings from user input, then sending them to the controller for validation there:
// The UI widget sends raw data
ElevatedButton(
onPressed: () {
ref.read(authNotifierProvider.notifier).login(
emailController.text,
passwordController.text,
);
},
child: const Text('Login'),
)
// The State Controller layer (Notifier) processes validation
class AuthNotifier extends AutoDisposeAsyncNotifier<AuthState> {
@override
Future<AuthState> build() async => const AuthState.unauthenticated();
Future<void> login(String email, String password) async {
// Run validation here before hitting the server API
if (email.trim().isEmpty) {
throw ValidationException('Email address cannot be empty!');
}
if (!email.contains('@')) {
throw ValidationException('Email address format is invalid!');
}
if (password.length < 8) {
throw ValidationException('Password must be at least 8 characters!');
}
state = const AsyncLoading();
try {
final user = await ref.read(authRepositoryProvider).signIn(email, password);
state = AsyncData(AuthState.authenticated(user));
} catch (e, stack) {
state = AsyncError(e, stack);
}
}
}
Writing validation in the business layer lets you create unit tests to verify whether empty email or short password validation works correctly without needing a simulator to run the app UI.
7. Writing Unit Tests Without the Flutter Widget Tree #
One of the biggest advantages of separating logic from the UI is the ease of writing automated tests. Good unit tests must run very fast (in milliseconds) and must not depend on Flutter visual components.
Here’s an example of how to test your AuthNotifier logic purely using Dart unit tests:
// test/auth_notifier_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
// ... import model and notifier
void main() {
group('Authentication Notifier Testing', () {
late ProviderContainer container;
late MockAuthRepository mockRepo;
setUp(() {
mockRepo = MockAuthRepository();
container = ProviderContainer(
overrides: [
// Replacing the real repository with a mock for testing isolation
authRepositoryProvider.overrideWith((ref) => mockRepo),
],
);
});
tearDown(() {
container.dispose();
});
test('Must throw ValidationException if email has no @ character', () async {
final notifier = container.read(authNotifierProvider.notifier);
expect(
() => notifier.login('wrongemail.com', 'password123'),
throwsA(isA<ValidationException>()),
);
});
test('Must change state to authenticated after a successful login', () async {
final userMock = User(id: '100', name: 'Budi');
// Setting up the mock repository to return the user when called
when(mockRepo.signIn('[email protected]', 'password123'))
.thenAnswer((_) => Future.value(userMock));
final notifier = container.read(authNotifierProvider.notifier);
await notifier.login('[email protected]', 'password123');
final endState = container.read(authNotifierProvider);
expect(endState.value?.isAuthenticated, isTrue);
expect(endState.value?.currentUser?.name, equals('Budi'));
});
});
}
8. Avoiding the God Notifier Anti-Pattern #
Just like the God State, you must avoid creating giant Notifier or Bloc classes handling too much unrelated business logic. Fattening state controller classes too much makes code hard to read, complicates testing, and lowers your app’s performance.
Split that business logic into controller classes focused on a single domain of responsibility:
┌────────────────────────┐
│ Our Store App │
└───────────┬────────────┘
│
┌────────────────────┼────────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ AuthNotifier │ │ CartNotifier │ │ProductNotifier │
├────────────────┤ ├────────────────┤ ├────────────────┤
│ Login Logic │ │ Add Item │ │ Fetch Products │
│ Logout Logic │ │ Remove Item │ │ Search Filter │
│ Token Mgmt │ │ Discount Calc │ │ Price Sort │
└────────────────┘ └────────────────┘ └────────────────┘
If Notifier A needs data from Notifier B (e.g., CartNotifier needs the user ID data from AuthNotifier to do checkout), use the dependency injection mechanism or provider reading provided by your state management library of choice:
// Reading another Notifier inside Riverpod
class CartNotifier extends AutoDisposeAsyncNotifier<CartState> {
@override
Future<CartState> build() async => const CartState();
Future<void> checkout() async {
// Accessing auth state safely
final authState = ref.read(authNotifierProvider);
final userId = authState.user.id;
await ref.read(orderRepositoryProvider).sendToServer(userId, state.items);
}
}
9. Memory Management: Disposing Resources #
Many Flutter apps experience memory leak problems after being used for a few minutes because developers forget to stop data observation processes or close stream connections running in the background.
Every time you open a stream connection, create an animation controller, use a timer, or register a MobX reaction listener, you must close it when the widget or store is no longer used on screen.
// CLOSING A STREAM IN BLOC:
class SensorBloc extends Bloc<SensorEvent, SensorState> {
late final StreamSubscription _sensorSubscription;
SensorBloc(SensorService service) : super(SensorInitial()) {
// Start listening to the sensor stream
_sensorSubscription = service.dataStream.listen((data) {
add(SensorDataUpdated(data));
});
}
@override
Future<void> close() {
// MANDATORY: Cancel the stream subscription when the Bloc is destroyed
_sensorSubscription.cancel();
return super.close();
}
}
If you’re using Riverpod, take advantage of the .autoDispose modifier. This will automatically dispose the Notifier and remove its data from device memory when the screen using it is closed by the user:
// State is automatically cleaned up when widgets no longer watch this provider
final productDetailProvider = FutureProvider.autoDispose.family<Product, String>((ref, id) {
return ref.read(productRepositoryProvider).fetchDetail(id);
});
10. State Management Review Checklist #
To ensure your app’s state management architecture quality stays intact before merging into the main code branch, use the following checklist as a reference during the code review process:
Design & Structure: #
- Are UI widgets clean of business logic calculations, input validation, and API calls?
- Is every data managed by a single source of truth (Single Source of Truth)?
- Are state classes defined immutably using the
finalkeyword and equipped with acopyWithmethod? - Is the state size designed granularly to avoid wasteful macro rebuilds?
Async & Error Handling: #
- Do all screens loading data from the internet display a loading animation (loading state)?
- On error, does the UI display an understandable error message and provide a retry button?
- Is there a widget active status check (
mounted) if usingsetStateafter an asyncawaitprocess finishes? - Are all API error responses handled structurally in the repository layer before being sent to the UI?
Performance & Efficiency: #
- Do UI widgets utilize reactivity filtering (like
selectin Riverpod/Provider, or granularObserverwidgets in MobX)? - Do we compare object changes using value comparison (like using the
equatablepackage or==operator overrides) to avoid unnecessary rebuilds? - Are derived data cached using computed properties or memoization mechanisms?
Testing & Maintenance: #
- Does the main business logic have adequate unit test coverage?
- Can unit tests run in isolation without requiring simulator/widget tree initialization?
- Are all external dependencies (like network API modules or local databases) mocked using mocking libraries?
- Have we disposed all subscriptions, timers, reactions, and controllers when the widget is destroyed (dispose)?
Summary #
- UI and Logic Separation: UI widgets should only focus on displaying visuals. All calculations, form validations, and data orchestration must be moved to the state controller layer (Notifier/Bloc/Store).
- Single Source of Truth: Avoid copying global data into local state. Use real-time selection mechanisms so data stays consistent across all app parts.
- Immutable State: Always design state classes with
finalproperties and update their values by emitting new objects fromcopyWithcopies.- Granular Modules: Don’t create one giant state controller to manage the whole app. Divide your business logic into small modules based on feature/data domain.
- Complete Async Conditions: Never skip handling the loading and error conditions when displaying data fetched from external servers.
- Independent Unit Testing: Clean business logic separation enables very fast automated unit test writing without dependence on the Flutter UI Framework.
- Disciplined Disposal: Cancel all stream subscriptions, dispose reactions, and clear controllers when the store or widget is closed to avoid memory leaks.