mobile-state-management-patterns
Comparison of mobile state management patterns (MVVM, MVI, TEA, Redux, BLoC, Observable, Compose state, SwiftUI state) and how to choose. Use when designing state flow or evaluating a state library.
Install
mkdir -p .claude/skills/mobile-state-management-patterns && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/15220" && unzip -o skill.zip -d .claude/skills/mobile-state-management-patterns && rm skill.zipInstalls to .claude/skills/mobile-state-management-patterns
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Comparison of mobile state management patterns (MVVM, MVI, TEA, Redux, BLoC, Observable, Compose state, SwiftUI state) and how to choose. Use when designing state flow or evaluating a state library.About this skill
Mobile State Management Patterns
Instructions
State management in mobile is the discipline of turning user and system events into UI, predictably. The right pattern depends on state complexity, team size, and framework idioms. This skill names the patterns and shows how they map across stacks.
1. Pattern Families
| Family | Shape | Good fit |
|---|---|---|
| MVVM | ViewModel exposes observable state | Most teams, all stacks |
| MVI / Unidirectional | State = reduce(state, intent) | Complex flows, testability |
| TEA (Elm-like) | Model, Msg, update, view | Compose MP, Bloc, Redux Toolkit |
| Redux / Flux | Single store, actions, reducers | Cross-platform with many contributors |
| Observable / Reactive | Streams of state | RN with RxJS/MobX, Combine, Flow |
| Local / ambient | Setters in the view | Very small features only |
2. State Scope Tiers
Classify state before choosing a library:
- UI-ephemeral -- hover, pressed, scroll position. Lives in the view.
- Screen -- loading flag, form draft. Lives in the view model.
- Feature -- a multi-screen flow (checkout). Shared view model or feature store.
- App -- user session, theme, connectivity. Global store.
- Persisted -- survives process death. Backed by disk/keychain.
Using a global store for UI-ephemeral state is the most common smell; so is using local state for app-wide concerns.
3. The Unidirectional Core
Regardless of library, prefer one-way flow:
Event (intent) -> reducer/update -> new State -> View renders
This makes time-travel debug, tests, and undo possible. Two-way bindings are an ergonomic shortcut for small forms, not an architectural default.
4. Cross-Platform Parallels
Counter with loading and error, the simplest non-trivial state:
// Swift (Observation / MV)
@Observable final class CounterVM {
private(set) var count = 0
private(set) var isLoading = false
var error: String?
func increment() async {
isLoading = true; defer { isLoading = false }
do { count = try await api.increment(count) } catch { self.error = "\(error)" }
}
}
// Kotlin (Compose + StateFlow)
class CounterVM(private val api: Api) : ViewModel() {
data class State(val count: Int = 0, val loading: Boolean = false, val error: String? = null)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state
fun increment() = viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
runCatching { api.increment(_state.value.count) }
.onSuccess { n -> _state.update { it.copy(count = n, loading = false) } }
.onFailure { e -> _state.update { it.copy(loading = false, error = e.message) } }
}
}
// Dart (Riverpod Notifier)
class CounterState {
const CounterState({this.count = 0, this.loading = false, this.error});
final int count; final bool loading; final String? error;
CounterState copyWith({int? count, bool? loading, String? error}) =>
CounterState(count: count ?? this.count, loading: loading ?? this.loading, error: error);
}
class CounterVM extends Notifier<CounterState> {
@override CounterState build() => const CounterState();
Future<void> increment() async {
state = state.copyWith(loading: true, error: null);
try { state = state.copyWith(count: await ref.read(apiProvider).increment(state.count), loading: false); }
catch (e) { state = state.copyWith(loading: false, error: e.toString()); }
}
}
// TypeScript (Zustand)
type CounterState = { count: number; loading: boolean; error?: string; increment: () => Promise<void> };
export const useCounter = create<CounterState>((set, get) => ({
count: 0, loading: false,
increment: async () => {
set({ loading: true, error: undefined });
try { const n = await api.increment(get().count); set({ count: n, loading: false }); }
catch (e: any) { set({ loading: false, error: String(e) }); }
},
}));
5. Choosing a Library
- Small app, single developer: platform-native (SwiftUI
@Observable, Composeremember/rememberSaveable, FlutterValueNotifier, RNuseState). - Medium app, testability priority: MVVM with observables (StateFlow, Riverpod, Combine, Zustand).
- Complex flows, multi-developer: MVI / TEA / Redux Toolkit / BLoC.
- Cross-platform shared logic (KMP): Coroutines + Flow with reducers in Kotlin, consumed by SwiftUI and Compose.
- RN with React team muscle: Zustand or Redux Toolkit; avoid context for anything that changes often.
6. Anti-Patterns
- Multiple sources of truth for the same fact (e.g., auth token in store and in a singleton).
- Reducers that do network I/O. Side effects belong in commands/middleware/effects.
- Storing derived data. Compute on read; cache with memoization only if profiled.
- Sharing
BuildContext/UIViewController/Contextacross state layers. - Global stores populated eagerly at app start. Hydrate lazily.
7. State Persistence
Separate transient state from persisted state. Good boundaries:
SavedStateHandleon Android restores small UI state across process death.@SceneStorage/@AppStorageon iOS.shared_preferences/ secure storage on Flutter for small values.- AsyncStorage / MMKV on RN for small values.
Large or sensitive state belongs in a database or keychain, not in user preferences.
8. Testing State
- Pure reducers/update functions should be 100% covered by unit tests.
- View models should be tested against fakes of their dependencies.
- UI tests assert on observable state, not on widget internals.
- Use time control (virtual clock,
TestScheduler,fakeAsync) instead of real delays.
Checklist
- Every stateful feature classifies its state scope explicitly.
- Data flow is unidirectional (event -> state -> view) unless a small local form justifies two-way.
- Side effects are isolated from reducers/update functions.
- Persisted state is separated from transient state.
- There is a single source of truth per fact.
- View models are testable without the view.
- Library choice matches team size, app complexity, and stack idioms.