Skip to main content

Cross-Platform State Management: Redux, Riverpod, and MobX

Created: February 17, 2026 Larry Qu 14 min read

Introduction

State management is the backbone of any non-trivial mobile application. As apps grow in complexity — handling authentication, offline data, real-time updates, and optimistic UI — choosing the right state management strategy becomes critical. In 2026, the ecosystem offers mature solutions across both React Native and Flutter, each with distinct philosophies around reactivity, immutability, and boilerplate. This guide covers local vs global state, solution comparisons, and implementation patterns for production-grade apps.

Understanding State in Mobile Apps

Local vs Global State

State in mobile applications exists at multiple levels. Understanding when to use each type prevents architectural over-engineering:

State Type Scope Persistence Examples
Local component state Single component None Form input, toggle states
Local widget state Widget subtree None Scroll position, animation values
Shared state Multiple screens None Auth token, user profile
Persistent state App-wide Disk/DB Preferences, cached data
Server state Remote origin Cache API responses, real-time data
URL/Route state Navigation Navigation tree Current screen, params

Local state belongs to a single component or widget. Use useState in React or StatefulWidget in Flutter for transient UI state like text input values, dropdown selections, or animation progress.

Global state is shared across multiple screens or components. This includes authentication status, user preferences, cart contents, and application settings. Global state requires careful management to avoid unnecessary re-renders and to maintain consistency across the app.

When to Use Each Approach

Deciding between local and global state depends on where the data is consumed:

// ✅ Local state - belongs to one component
function SearchInput() {
  const [query, setQuery] = useState('');

  return (
    <TextInput
      value={query}
      onChangeText={setQuery}
      placeholder="Search..."
    />
  );
}

// ❌ Premature global state
// Don't put search input in global store unless needed across screens
// ✅ Global state - shared across screens
// authStore.ts
interface AuthState {
  user: User | null;
  token: string | null;
  isLoading: boolean;
}

// Consumed by: ProfileScreen, SettingsScreen, CheckoutScreen
function useAuth() {
  return useAuthStore(state => ({
    user: state.user,
    isAuthenticated: !!state.token,
  }));
}

State Management Solutions Overview

Solution Platform Paradigm Bundle Size Learning Curve
Redux Toolkit RN/Web Flux/Immer ~12 KB Medium
Zustand RN/Web Hook-based stores ~1 KB Low
MobX RN/Web Observable/Mutable ~16 KB Low-Medium
Jotai RN/Web Atomic atoms ~3 KB Low
Provider Flutter InheritedWidget Built-in Low
Riverpod Flutter Compile-safe providers ~50 KB Low-Medium
Bloc Flutter Event/State stream ~30 KB Medium-High

Redux Toolkit in React Native

Redux remains the most widely adopted state management solution for React Native. Redux Toolkit (RTK) significantly reduces boilerplate compared to vanilla Redux by providing createSlice, configureStore, and built-in Thunk middleware.

Store Configuration

Set up the store with RTK’s configureStore:

import { configureStore } from '@reduxjs/toolkit';
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import authReducer from './slices/authSlice';
import cartReducer from './slices/cartSlice';
import productsReducer from './slices/productsSlice';

export const store = configureStore({
  reducer: {
    auth: authReducer,
    cart: cartReducer,
    products: productsReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['auth/login/fulfilled'],
      },
    }),
  devTools: __DEV__,
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Creating Slices with createSlice

Define state slices with reducers and async thunks:

import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { authApi } from '../../api/auth';

interface AuthState {
  user: User | null;
  token: string | null;
  isLoading: boolean;
  error: string | null;
}

const initialState: AuthState = {
  user: null,
  token: null,
  isLoading: false,
  error: null,
};

export const login = createAsyncThunk(
  'auth/login',
  async (credentials: LoginCredentials, { rejectWithValue }) => {
    try {
      const response = await authApi.login(credentials);
      await SecureStore.setItemAsync('token', response.token);
      return response;
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    logout(state) {
      state.user = null;
      state.token = null;
    },
    updateProfile(state, action: PayloadAction<Partial<User>>) {
      if (state.user) {
        state.user = { ...state.user, ...action.payload };
      }
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(login.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(login.fulfilled, (state, action) => {
        state.isLoading = false;
        state.user = action.payload.user;
        state.token = action.payload.token;
      })
      .addCase(login.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.payload as string;
      });
  },
});

export const { logout, updateProfile } = authSlice.actions;
export default authSlice.reducer;

Using Redux in Components

Connect components with typed hooks:

import { useAppDispatch, useAppSelector } from '../../store';

function ProfileScreen() {
  const dispatch = useAppDispatch();
  const { user, isLoading } = useAppSelector((state) => state.auth);

  const handleLogout = () => {
    dispatch(logout());
  };

  if (isLoading) {
    return <LoadingSpinner />;
  }

  return (
    <View>
      <Text>{user?.name}</Text>
      <Text>{user?.email}</Text>
      <Button title="Logout" onPress={handleLogout} />
    </View>
  );
}

Redux Persistence

Persist and rehydrate Redux state across app restarts:

import { persistStore, persistReducer } from 'redux-persist';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { PersistGate } from 'redux-persist/integration/react';

const persistConfig = {
  key: 'root',
  storage: AsyncStorage,
  whitelist: ['auth', 'settings'], // Only persist these slices
  blacklist: ['products'], // Don't persist server cache
};

const persistedReducer = persistReducer(persistConfig, rootReducer);

export const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
      },
    }),
});

export const persistor = persistStore(store);

// In App.tsx
function App() {
  return (
    <Provider store={store}>
      <PersistGate loading={<SplashScreen />} persistor={persistor}>
        <RootNavigator />
      </PersistGate>
    </Provider>
  );
}

Zustand for React Native

Zustand offers a minimal, hook-based alternative to Redux with significantly less boilerplate. Its API is built around small, independent stores.

Creating a Zustand Store

Define stores as hooks with built-in selectors:

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  total: number;
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  updateQuantity: (id: string, quantity: number) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      total: 0,

      addItem: (item) =>
        set((state) => {
          const existing = state.items.find((i) => i.id === item.id);
          if (existing) {
            return {
              items: state.items.map((i) =>
                i.id === item.id
                  ? { ...i, quantity: i.quantity + 1 }
                  : i
              ),
            };
          }
          return {
            items: [...state.items, { ...item, quantity: 1 }],
          };
        }),

      removeItem: (id) =>
        set((state) => ({
          items: state.items.filter((i) => i.id !== id),
        })),

      updateQuantity: (id, quantity) =>
        set((state) => ({
          items: quantity <= 0
            ? state.items.filter((i) => i.id !== id)
            : state.items.map((i) =>
                i.id === id ? { ...i, quantity } : i
              ),
        })),

      clearCart: () => set({ items: [], total: 0 }),
    }),
    {
      name: 'cart-storage',
      storage: createJSONStorage(() => AsyncStorage),
      partialize: (state) => ({
        items: state.items,
      }),
    }
  )
);

// Computed value subscription
export const useCartTotal = () =>
  useCartStore((state) =>
    state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );

Using Zustand in Components

Zustand integrates naturally with React’s hooks model. Selectors prevent unnecessary re-renders by subscribing only to specific state fragments:

function CartScreen() {
  const items = useCartStore((state) => state.items);
  const total = useCartTotal();
  const { addItem, removeItem } = useCartStore();

  return (
    <FlatList
      data={items}
      renderItem={({ item }) => (
        <CartItemRow
          item={item}
          onIncrement={() => addItem(item)}
          onDecrement={() => removeItem(item.id)}
        />
      )}
      ListFooterComponent={<Text>Total: ${total.toFixed(2)}</Text>}
    />
  );
}

Zustand Middleware

Zustand supports middleware for logging, persistence, and devtools:

import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';

interface AnalyticsStore {
  events: AnalyticsEvent[];
  track: (event: AnalyticsEvent) => void;
  flush: () => Promise<void>;
}

export const useAnalyticsStore = create<AnalyticsStore>()(
  devtools(
    subscribeWithSelector((set, get) => ({
      events: [],

      track: (event) =>
        set((state) => ({
          events: [...state.events, event],
        })),

      flush: async () => {
        const events = get().events;
        if (events.length > 0) {
          await analyticsApi.sendBatch(events);
          set({ events: [] });
        }
      },
    })),
    { name: 'AnalyticsStore' }
  )
);

// Subscribe to specific state changes
useAnalyticsStore.subscribe(
  (state) => state.events.length,
  (length) => {
    if (length >= 10) {
      useAnalyticsStore.getState().flush();
    }
  }
);

MobX in React Native

MobX takes a reactive, observable-based approach where state changes automatically propagate to all observers. Its mutable API feels natural to many developers.

Observable Stores

Define stores with makeAutoObservable:

import { makeAutoObservable, runInAction } from 'mobx';
import { observer } from 'mobx-react-lite';

class ProductStore {
  products: Product[] = [];
  selectedCategory: string | null = null;
  isLoading = false;
  searchQuery = '';

  constructor() {
    makeAutoObservable(this);
  }

  // Computed property - auto-derives from state
  get filteredProducts(): Product[] {
    let result = this.products;

    if (this.selectedCategory) {
      result = result.filter((p) => p.category === this.selectedCategory);
    }

    if (this.searchQuery) {
      result = result.filter((p) =>
        p.name.toLowerCase().includes(this.searchQuery.toLowerCase())
      );
    }

    return result;
  }

  get categories(): string[] {
    return [...new Set(this.products.map((p) => p.category))];
  }

  setSearchQuery(query: string) {
    this.searchQuery = query;
  }

  setSelectedCategory(category: string | null) {
    this.selectedCategory = category;
  }

  async fetchProducts() {
    this.isLoading = true;
    try {
      const response = await api.getProducts();
      runInAction(() => {
        this.products = response.data;
        this.isLoading = false;
      });
    } catch (error) {
      runInAction(() => {
        this.isLoading = false;
      });
    }
  }
}

const productStore = new ProductStore();
export default productStore;

Observer Components

Wrap components with observer to auto-track dependencies:

import { observer } from 'mobx-react-lite';
import productStore from './stores/productStore';

const ProductList = observer(() => {
  const { filteredProducts, isLoading, setSearchQuery, searchQuery } =
    productStore;

  return (
    <View>
      <TextInput
        value={searchQuery}
        onChangeText={setSearchQuery}
        placeholder="Search products..."
        style={styles.searchInput}
      />
      {isLoading ? (
        <LoadingSpinner />
      ) : (
        <FlatList
          data={filteredProducts}
          renderItem={({ item }) => <ProductCard product={item} />}
          keyExtractor={(item) => item.id}
        />
      )}
    </View>
  );
});

MobX tracks which observables each component accesses and only re-renders when those specific values change. This granular reactivity prevents the unnecessary re-renders common in Redux without requiring manual selector optimization.

Jotai for Atomic State

Jotai provides an atomic state model where atoms are independent units of state that can be composed together. This approach scales naturally from simple to complex applications.

import { atom, useAtom, useAtomValue } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import AsyncStorage from '@react-native-async-storage/async-storage';

// Base atoms
const searchQueryAtom = atom('');
const selectedFiltersAtom = atom<Filter[]>([]);
const pageAtom = atom(1);

// Persistent atom
const recentSearchesAtom = atomWithStorage<string[]>(
  'recent-searches',
  [],
  {
    getItem: async (key) => {
      const item = await AsyncStorage.getItem(key);
      return item ? JSON.parse(item) : [];
    },
    setItem: async (key, value) => {
      await AsyncStorage.setItem(key, JSON.stringify(value));
    },
    removeItem: async (key) => {
      await AsyncStorage.removeItem(key);
    },
  }
);

// Derived atoms
const searchParamsAtom = atom((get) => ({
  query: get(searchQueryAtom),
  filters: get(selectedFiltersAtom),
  page: get(pageAtom),
}));

const searchUrlAtom = atom((get) => {
  const params = get(searchParamsAtom);
  const query = new URLSearchParams({
    q: params.query,
    filters: JSON.stringify(params.filters),
    page: String(params.page),
  });
  return `/api/search?${query}`;
});

// Async atom
const searchResultsAtom = atom(async (get) => {
  const url = get(searchUrlAtom);
  const response = await fetch(url);
  return response.json();
});

// Usage
function SearchScreen() {
  const [query, setQuery] = useAtom(searchQueryAtom);
  const results = useAtomValue(searchResultsAtom);

  return (
    <View>
      <TextInput value={query} onChangeText={setQuery} />
      <SearchResults data={results} />
    </View>
  );
}

Flutter State Management

Provider

Provider is the simplest state management solution for Flutter, wrapping InheritedWidget to expose values down the widget tree:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

// Model
class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

// Provide the model at the app root
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CounterModel(),
      child: const MyApp(),
    ),
  );
}

// Consume in widgets
class CounterWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Rebuilds when CounterModel changes
        Text('Count: ${context.watch<CounterModel>().count}'),
        ElevatedButton(
          onPressed: () => context.read<CounterModel>().increment(),
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

Provider works well for simple apps but lacks compile-time safety — accessing a non-existent provider throws a runtime error.

Riverpod

Riverpod addresses Provider’s limitations with compile-time safety, better testability, and support for multiple provider types:

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'main.g.dart';

// Simple provider
final greetingProvider = Provider<String>((ref) => 'Hello, Riverpod!');

// StateNotifier for mutable state
class AuthNotifier extends StateNotifier<AuthState> {
  AuthNotifier(this._authService) : super(AuthState.initial());

  final AuthService _authService;

  Future<void> login(String email, String password) async {
    state = state.copyWith(isLoading: true);
    try {
      final user = await _authService.login(email, password);
      state = AuthState.authenticated(user);
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        error: e.toString(),
      );
    }
  }
}

final authProvider =
    StateNotifierProvider<AuthNotifier, AuthState>((ref) {
  return AuthNotifier(ref.watch(authServiceProvider));
});

// Async provider with auto-dispose
final userPostsProvider = FutureProvider.family<List<Post>, String>(
  (ref, userId) async {
    final api = ref.watch(apiServiceProvider);
    return api.getUserPosts(userId);
  },
  name: 'userPosts',
);

// Computed provider
final filteredPostsProvider = Provider.family<List<Post>, String>(
  (ref, searchTerm) {
    final allPosts = ref.watch(allPostsProvider);
    if (searchTerm.isEmpty) return allPosts;
    return allPosts
        .where((p) => p.title.toLowerCase().contains(searchTerm.toLowerCase()))
        .toList();
  },
);

Consume providers in widgets:

class LoginScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final authState = ref.watch(authProvider);
    final greeting = ref.watch(greetingProvider);

    return Scaffold(
      appBar: AppBar(title: Text(greeting)),
      body: authState.when(
        initial: () => LoginForm(),
        loading: () => const CircularProgressIndicator(),
        authenticated: (user) => Dashboard(user: user),
        error: (message) => ErrorWidget(message: message),
      ),
    );
  }
}

Bloc

Bloc (Business Logic Component) enforces a strict event-driven architecture. State changes are triggered by events processed through streams:

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';

// Events
abstract class TodoEvent extends Equatable {
  const TodoEvent();
  @override
  List<Object?> get props => [];
}

class AddTodo extends TodoEvent {
  final String title;
  const AddTodo(this.title);
  @override
  List<Object?> get props => [title];
}

class ToggleTodo extends TodoEvent {
  final String id;
  const ToggleTodo(this.id);
  @override
  List<Object?> get props => [id];
}

// State
class TodoState extends Equatable {
  final List<Todo> todos;
  final bool isLoading;

  const TodoState({this.todos = const [], this.isLoading = false});

  TodoState copyWith({List<Todo>? todos, bool? isLoading}) {
    return TodoState(
      todos: todos ?? this.todos,
      isLoading: isLoading ?? this.isLoading,
    );
  }

  @override
  List<Object?> get props => [todos, isLoading];
}

// Bloc
class TodoBloc extends Bloc<TodoEvent, TodoState> {
  TodoBloc() : super(const TodoState()) {
    on<AddTodo>(_onAddTodo);
    on<ToggleTodo>(_onToggleTodo);
  }

  Future<void> _onAddTodo(AddTodo event, Emitter<TodoState> emit) async {
    emit(state.copyWith(isLoading: true));
    final newTodo = Todo(id: uuid.v4(), title: event.title);
    emit(state.copyWith(
      todos: [...state.todos, newTodo],
      isLoading: false,
    ));
  }

  void _onToggleTodo(ToggleTodo event, Emitter<TodoState> emit) {
    emit(state.copyWith(
      todos: state.todos.map((todo) {
        if (todo.id == event.id) {
          return todo.copyWith(completed: !todo.completed);
        }
        return todo;
      }).toList(),
    ));
  }
}

// Usage
class TodoPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => TodoBloc(),
      child: BlocBuilder<TodoBloc, TodoState>(
        builder: (context, state) {
          if (state.isLoading) {
            return const CircularProgressIndicator();
          }
          return ListView.builder(
            itemCount: state.todos.length,
            itemBuilder: (context, index) {
              final todo = state.todos[index];
              return ListTile(
                title: Text(todo.title),
                leading: Checkbox(
                  value: todo.completed,
                  onChanged: (_) {
                    context.read<TodoBloc>().add(ToggleTodo(todo.id));
                  },
                ),
              );
            },
          );
        },
      ),
    );
  }
}

State Management Architecture Patterns

Clean Architecture with State Management

Divide state responsibilities into layers:

// Domain layer - entities and use cases
interface UserEntity {
  id: string;
  name: string;
  email: string;
}

// Data layer - repositories and data sources
class UserRepositoryImpl implements UserRepository {
  async getUser(id: string): Promise<UserEntity> {
    const response = await api.get(`/users/${id}`);
    return UserMapper.fromApi(response.data);
  }
}

// Presentation layer - state management
class UserBloc {
  private repository: UserRepository;
  private userState = signal<UserState>({ loading: true });

  async loadUser(id: string) {
    try {
      const user = await this.repository.getUser(id);
      this.userState.set({ user, loading: false });
    } catch (error) {
      this.userState.set({ error, loading: false });
    }
  }
}

Server State with React Query/TanStack Query

Server state (API data) has different requirements than client state:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// Fetch with caching
function useProducts(category: string | null) {
  return useQuery({
    queryKey: ['products', category],
    queryFn: () => api.getProducts(category),
    staleTime: 5 * 60 * 1000, // 5 minutes
    gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
    retry: 3,
    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
  });
}

// Mutation with optimistic updates
function useCreateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (product: NewProduct) => api.createProduct(product),
    onMutate: async (newProduct) => {
      await queryClient.cancelQueries({ queryKey: ['products'] });
      const previous = queryClient.getQueryData(['products']);
      queryClient.setQueryData(['products'], (old: Product[]) => [
        { ...newProduct, id: 'temp-id', status: 'pending' },
        ...(old || []),
      ]);
      return { previous };
    },
    onError: (err, newProduct, context) => {
      queryClient.setQueryData(['products'], context?.previous);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });
}

Performance Considerations

Re-render Optimization

Prevent unnecessary re-renders with proper state selection:

// ❌ Bad - subscribes to entire store
function UserName() {
  const state = useAppSelector((state) => state);
  return <Text>{state.auth.user?.name}</Text>;
}

// ✅ Good - subscribes only to needed slice
function UserName() {
  const userName = useAppSelector((state) => state.auth.user?.name);
  return <Text>{userName}</Text>;
}

// ✅ Best - use shallow equality for objects
function UserProfile() {
  const user = useAppSelector(
    (state) => state.auth.user,
    shallowEqual // Only re-render if user object changes reference
  );
  return <ProfileCard user={user} />;
}

State Normalization

Normalize nested data to prevent cascading updates:

// ❌ Bad - nested state
interface BlogPost {
  id: string;
  title: string;
  author: { id: string; name: string };
  comments: Comment[];
}

// ✅ Good - normalized state
interface NormalizedState {
  posts: { [id: string]: Post };
  authors: { [id: string]: Author };
  comments: { [id: string]: Comment };
  postIds: string[];
}

State Management Decision Guide

App Complexity React Native Flutter
Simple (1-5 screens) useState + Context Provider
Medium (5-15 screens) Zustand or Jotai Riverpod
Complex (15+ screens) Redux Toolkit Bloc
Data-heavy (dashboards) TanStack Query + Zustand Riverpod + Freezed
Real-time (chat, games) Zustand + WebSocket Bloc + Streams

Resources

Comments

👍 Was this article helpful?