agentskills.codes
MO

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.zip

Installs 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.
198 chars✓ has a “when” trigger

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

FamilyShapeGood fit
MVVMViewModel exposes observable stateMost teams, all stacks
MVI / UnidirectionalState = reduce(state, intent)Complex flows, testability
TEA (Elm-like)Model, Msg, update, viewCompose MP, Bloc, Redux Toolkit
Redux / FluxSingle store, actions, reducersCross-platform with many contributors
Observable / ReactiveStreams of stateRN with RxJS/MobX, Combine, Flow
Local / ambientSetters in the viewVery small features only

2. State Scope Tiers

Classify state before choosing a library:

  1. UI-ephemeral -- hover, pressed, scroll position. Lives in the view.
  2. Screen -- loading flag, form draft. Lives in the view model.
  3. Feature -- a multi-screen flow (checkout). Shared view model or feature store.
  4. App -- user session, theme, connectivity. Global store.
  5. 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, Compose remember/rememberSaveable, Flutter ValueNotifier, RN useState).
  • 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 / Context across state layers.
  • Global stores populated eagerly at app start. Hydrate lazily.

7. State Persistence

Separate transient state from persisted state. Good boundaries:

  • SavedStateHandle on Android restores small UI state across process death.
  • @SceneStorage / @AppStorage on 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.

Search skills

Search the agent skills registry