
State Machines: The Missing Piece in Your App Architecture
WEDNESDAY MARCH 11 2026 - 9 MIN
The bug that shouldn't exist
Here's a bug report that every app developer has seen:
"Loading spinner keeps showing after the data loads."
You check the code. The isLoading flag is set to false. The data is there. Yet the spinner renders. You add a null check, deploy, and close the ticket. A week later: "Button is disabled even when the form is valid." Same root cause. Different symptom.
The problem isn't your logic, it's your state model. When you represent application state as a bag of booleans and nullable fields, you're creating states that cannot exist in the real world but can exist in your code.
State machines eliminate this class of bugs entirely.
What is a finite state machine?
A Finite State Machine (FSM) is a computational model that:
- Has a finite set of states, with exactly one active at a time.
- Transitions between states in response to events.
- Can only be in valid, explicitly defined configurations.
The key constraint (only one state active at a time) is what kills impossible states. There's no room for isLoading: true, data: someValue, error: someError to coexist.
Here's the canonical example: fetching remote data.
Four states. Three events. Every transition is explicit. There's no path to loading + success simultaneously. It's structurally impossible.
The boolean trap
Before showing the FSM solution, let's autopsy the pattern it replaces:
class UserProfileState {
final bool isLoading;
final bool isRetrying;
final UserProfile? data;
final String? errorMessage;
final bool hasRefreshed;
const UserProfileState({
this.isLoading = false,
this.isRetrying = false,
this.data,
this.errorMessage,
this.hasRefreshed = false,
});
}
How many combinations does this represent? With 3 booleans and 2 nullable fields: astronomically many. How many are valid? Maybe 5. Yet your if chains have to guard against all the invalid ones.
The UI ends up riddled with defensive checks:
// Are we loading? But not if we have data already? But what about retry?
if (state.isLoading && state.data == null && !state.isRetrying) {
return const LoadingSpinner();
}
You're compensating in the UI for a broken state model. Every defensive check is a bug waiting to be written wrong.
Modeling state as a sealed union
Dart's sealed classes (available since Dart 3.0) combined with Freezed give you exhaustive pattern matching, the perfect foundation for FSMs.
@freezed
sealed class RemoteData<T> with _$RemoteData<T> {
const factory RemoteData.idle() = RemoteDataIdle<T>;
const factory RemoteData.loading() = RemoteDataLoading<T>;
const factory RemoteData.success({required T data}) = RemoteDataSuccess<T>;
const factory RemoteData.failure({required String message}) = RemoteDataFailure<T>;
}
This is the FSM encoded in the type system. The compiler enforces the invariant: you cannot construct a state that doesn't exist in this union. No isLoading + data combination is representable.
The UI switch is now exhaustive. Miss a case and the compiler errors:
return switch (state) {
RemoteDataIdle() => const _IdlePlaceholder(),
RemoteDataLoading() => const _LoadingShimmer(),
RemoteDataSuccess(:final data) => _ProfileContent(profile: data),
RemoteDataFailure(:final message) => _ErrorView(message: message),
};
No defensive checks. No ! operators. No null assertions. The state itself tells the widget what to render.
Building the state machine with Riverpod
Let's build a complete, real-world example: a user profile screen with fetch, retry, and refresh.
The state
// lib/features/profile/models/profile_state.dart
@freezed
sealed class ProfileState with _$ProfileState {
const factory ProfileState.idle() = ProfileIdle;
const factory ProfileState.loading() = ProfileLoading;
const factory ProfileState.refreshing({required UserProfile current}) = ProfileRefreshing;
const factory ProfileState.success({required UserProfile profile}) = ProfileSuccess;
const factory ProfileState.failure({
required String message,
required Object error,
}) = ProfileFailure;
}
Note the refreshing state, which carries the current data so the UI can show the existing content alongside a refresh indicator. This is a transition that the boolean model handles awkwardly with isRefreshing && data != null.
The notifier
The StateNotifier is where transitions are enforced. Each public method represents an event, and the internal logic governs which transitions are valid.
// lib/features/profile/providers/profile_notifier.dart
class ProfileNotifier extends StateNotifier<ProfileState> {
ProfileNotifier({required this.repo}) : super(const ProfileState.idle());
final ProfileRepository repo;
Future<void> load() async {
// Guard: only transition from idle or failure
if (state is! ProfileIdle && state is! ProfileFailure) return;
state = const ProfileState.loading();
state = await AsyncValue.guard(() => repo.fetchProfile()).when(
data: (profile) => ProfileState.success(profile: profile),
error: (e, _) => ProfileState.failure(
message: _humanReadableError(e),
error: e,
),
loading: () => state, // unreachable, but required for exhaustiveness
);
}
Future<void> refresh() async {
// Guard: can only refresh from a success state
final current = switch (state) {
ProfileSuccess(:final profile) => profile,
_ => null,
};
if (current == null) return;
state = ProfileState.refreshing(current: current);
state = await AsyncValue.guard(() => repo.fetchProfile()).when(
data: (profile) => ProfileState.success(profile: profile),
error: (e, _) => ProfileState.failure(
message: _humanReadableError(e),
error: e,
),
loading: () => state,
);
}
Future<void> retry() async {
// Retry is semantically identical to load, but the guard differs
if (state is! ProfileFailure) return;
await load();
}
String _humanReadableError(Object e) =>
e is NetworkException ? e.userMessage : 'Something went wrong.';
}
final profileProvider = StateNotifierProvider.autoDispose<ProfileNotifier, ProfileState>((ref) {
return ProfileNotifier(repo: ref.watch(profileRepositoryProvider));
});
The guards at the start of each method are the transition table. load() cannot fire if you're already loading. The event is simply ignored. This is the FSM contract: invalid transitions are no-ops, not crashes.
The UI
// lib/features/profile/widgets/profile_screen.dart
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@override
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
@override
void initState() {
super.initState();
// Trigger initial load after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(profileProvider.notifier).load();
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(profileProvider);
return Scaffold(
body: switch (state) {
ProfileIdle() => const SizedBox.shrink(),
ProfileLoading() => const _ProfileSkeleton(),
ProfileRefreshing(:final current) => _ProfileView(
profile: current,
isRefreshing: true,
onRefresh: ref.read(profileProvider.notifier).refresh,
),
ProfileSuccess(:final profile) => _ProfileView(
profile: profile,
isRefreshing: false,
onRefresh: ref.read(profileProvider.notifier).refresh,
),
ProfileFailure(:final message) => _ErrorView(
message: message,
onRetry: ref.read(profileProvider.notifier).retry,
),
},
);
}
}
The switch is exhaustive. If you add a new state to ProfileState, this file won't compile until you handle it. The compiler becomes your architect review.
Hierarchical state machines
Simple screens map well to flat FSMs. Complex flows like onboarding, checkout, and auth benefit from hierarchical state machines (HSMs): states nested inside states.
Consider a checkout flow:
Each top-level state owns its own nested FSM. The AddressEntry step manages editing → validating → valid/invalid independently of the checkout flow's concern of CartReview → AddressEntry → PaymentEntry.
In Dart, model this with nested sealed classes:
@freezed
sealed class CheckoutState with _$CheckoutState {
const factory CheckoutState.cartReview({required Cart cart}) = CartReviewState;
const factory CheckoutState.addressEntry({
required Cart cart,
required AddressFormState formState,
}) = AddressEntryState;
const factory CheckoutState.paymentEntry({
required Cart cart,
required Address address,
required PaymentFormState formState,
}) = PaymentEntryState;
const factory CheckoutState.confirming({required OrderSummary summary}) = ConfirmingState;
const factory CheckoutState.success({required Order order}) = OrderSuccessState;
const factory CheckoutState.failure({required String reason}) = OrderFailureState;
}
@freezed
sealed class AddressFormState with _$AddressFormState {
const factory AddressFormState.editing() = AddressEditing;
const factory AddressFormState.validating() = AddressValidating;
const factory AddressFormState.valid({required Address address}) = AddressValid;
}
Each nested type is its own FSM. The parent CheckoutState transitions between steps; the child AddressFormState manages step-internal behavior.
Testing state machines
FSMs are the most testable architecture pattern. Each test is simply: "given state X, when event Y fires, assert state Z."
// test/features/profile/profile_notifier_test.dart
void main() {
late MockProfileRepository repo;
late ProfileNotifier notifier;
setUp(() {
repo = MockProfileRepository();
notifier = ProfileNotifier(repo: repo);
});
group('load()', () {
test('transitions idle → loading → success', () async {
final profile = fakeProfile();
when(() => repo.fetchProfile()).thenAnswer((_) async => profile);
// idle is the initial state
expect(notifier.state, const ProfileState.idle());
final loadFuture = notifier.load();
// synchronous check: must be loading immediately
expect(notifier.state, const ProfileState.loading());
await loadFuture;
expect(notifier.state, ProfileState.success(profile: profile));
});
test('transitions loading → failure on error', () async {
when(() => repo.fetchProfile()).thenThrow(NetworkException('timeout'));
await notifier.load();
expect(notifier.state, isA<ProfileFailure>());
});
test('is a no-op when already loading', () async {
// First call starts loading
final firstLoad = notifier.load();
// Second call fires while loading, should be ignored
await notifier.load();
await firstLoad;
// Only one fetch should have occurred
verify(() => repo.fetchProfile()).called(1);
});
});
group('refresh()', () {
test('is a no-op when not in success state', () async {
// State is idle, not success
await notifier.refresh();
verifyNever(() => repo.fetchProfile());
});
test('carries current data into refreshing state', () async {
final profile = fakeProfile();
when(() => repo.fetchProfile()).thenAnswer((_) async => profile);
await notifier.load();
// Now in success, set up a slow refresh
when(() => repo.fetchProfile()).thenAnswer(
(_) => Future.delayed(const Duration(seconds: 1), () => fakeProfile()),
);
final refreshFuture = notifier.refresh();
expect(
notifier.state,
ProfileState.refreshing(current: profile),
);
await refreshFuture;
});
});
}
Tests read as specifications. The test name is the transition being verified. No mocking UI framework interactions, no pump calls, no widget tree setup. Pure business logic.
When FSMs compose with Riverpod's dependency graph
One underused pattern: using Riverpod's provider graph to compose FSMs. Each provider is a state machine; they communicate through ref.watch and ref.listen.
// Auth state machine
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>(...);
// Profile state machine that depends on auth
final profileProvider = StateNotifierProvider.autoDispose<ProfileNotifier, ProfileState>((ref) {
// When auth transitions to unauthenticated, this provider disposes (autoDispose).
// When it recreates, ProfileNotifier starts fresh from idle.
final authState = ref.watch(authProvider);
return ProfileNotifier(
repo: ref.watch(profileRepositoryProvider),
userId: switch (authState) {
AuthAuthenticated(:final userId) => userId,
_ => throw StateError('Profile provider created without authenticated user'),
},
);
});
// Cross-machine reaction: listen to auth changes and drive logout navigation
ref.listen(authProvider, (_, next) {
if (next is AuthUnauthenticated) {
ref.read(routerProvider).go('/login');
}
});
The auth FSM's transition to unauthenticated automatically tears down the profile FSM. The provider graph is your state machine's topology.
Common pitfalls
1. Overcomplicating state when behavior is truly independent
If two pieces of state don't interact with each other, they should be separate providers, not folded into one FSM. A ProfileState that also models a tab selection is a red flag. Keep FSMs focused.
2. Putting guard logic in the UI
// Bad: UI deciding whether the event is valid
if (state is ProfileSuccess) {
ElevatedButton(onPressed: notifier.refresh, ...)
}
// Good: event is always callable; notifier guards internally
ElevatedButton(onPressed: notifier.refresh, ...)
The UI shouldn't know the valid transitions. That's the FSM's job.
3. Transitions that bypass the notifier
Never mutate state from outside the notifier. Every state change must be an event dispatched to the FSM. This preserves the audit trail and makes debugging trivial. Log every transition and you have a complete history.
4. Using AsyncValue as your state type
Riverpod's AsyncValue is convenient but it's a 3-state model (data, loading, error). Real UIs routinely need more states: idle, refreshing, paginating, submitting. AsyncValue is a great building block but a poor substitute for a purpose-built FSM.
The payoff
The mental model shift from "bag of booleans" to "state machine" is initially unfamiliar. The payoff arrives quickly:
- Impossible states are unrepresentable. The compiler enforces it.
- Every UI branch is accounted for. Exhaustive
switchwith no default fallbacks. - Tests are deterministic specifications. Transition-in, transition-out, verify.
- Debugging is trivial. Log state transitions and you have a complete event log.
- Onboarding is faster. A
stateDiagramis the most honest documentation your codebase can have.
The next time you reach for a boolean flag, ask: what state does this represent? Can it coexist with other flags in a way that shouldn't happen? If the answer is yes, you need a state machine.
For suggestions and queries, just contact me.