State management in mobile apps is like choosing your matatu route to town. Pick the right one, and you're there in 15 minutes flat. Pick the wrong one, and you're stuck in Waiyaki Way traffic for two hours wondering why you didn't just walk.

In 2025, we've got more state management options than Nairobi has matatus. Redux, Zustand, MobX, Context API, Jotai - each claiming to be the fastest route to clean code. But which one actually gets you there?

Today, we're putting them all head-to-head with real code examples. No theory. No philosophy. Just practical guidance for building apps that won't have you debugging at 3 AM with buggy red eyes and shitty coffee for fuel and company.

For Flutter developers, check out Flutter Riverpod state management - a completely different (and arguably better) approach than React patterns. React Native developers should also read navigation patterns which integrate tightly with state management.

The Contenders

  1. Context API: Built into React. Like the free government ambulance - it's there, but you don't want to rely on it for everything.
  2. Redux Toolkit (RTK): The matatu industry standard. Loud, structured, gets the job done.
  3. Zustand: The sleek Uber. Clean, simple, surprisingly capable.
  4. MobX: The magic/familiar boda boda that seems to know where you're going before you do.
  5. Jotai: The atomic approach. Like having 100 bodas instead of one bus.

1. React Context API: The Built-In Option

Context is already in React. Zero npm install, zero bundle size increase. It's great for things that rarely change - like whether someone's logged in or if they prefer dark mode.

Best for: Theme settings, auth state, user preferences

Avoid for: Shopping carts, form data, anything that updates frequently

// AuthContext.tsx
import React, { createContext, useState, useContext } from 'react';

type AuthContextType = {
  user: User | null;
  login: (userData: User) => void;
  logout: () => void;
};

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState<User | null>(null);

  const login = (userData: User) => setUser(userData);
  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  return context;
};

The Problem: Every time auth state changes, EVERY component using useAuth re-renders. For a login button? Fine. For a component tree with 50 children? Your app just turned into a slideshow.

2. Redux Toolkit: When Your Team Is Bigger Than 3

Redux used to be boilerplate hell. Redux Toolkit (RTK) fixed that. If you're still writing action creators manually, you're doing 2018 Redux in 2025.

Best for: Large teams, complex apps, when you need time-travel debugging

Overkill for: Todo apps, personal projects, anything that could be a spreadsheet

// store.ts
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [], total: 0 },
  reducers: {
    addItem: (state, action: PayloadAction<CartItem>) => {
      // Redux Toolkit uses Immer - you can "mutate" state
      state.items.push(action.payload);
      state.total += action.payload.price;
    },
    removeItem: (state, action: PayloadAction<string>) => {
      const index = state.items.findIndex(item => item.id === action.payload);
      if (index !== -1) {
        state.total -= state.items[index].price;
        state.items.splice(index, 1);
      }
    },
  },
});

export const { addItem, removeItem } = cartSlice.actions;

export const store = configureStore({
  reducer: {
    cart: cartSlice.reducer,
  },
});

// Component Usage
import { useDispatch, useSelector } from 'react-redux';

function Cart() {
  const items = useSelector((state: RootState) => state.cart.items);
  const dispatch = useDispatch();

  return (
    <View>
      {items.map(item => (
        <View key={item.id}>
          <Text>{item.name}</Text>
          <Button onPress={() => dispatch(removeItem(item.id))} title="Remove" />
        </View>
      ))}
    </View>
  );
}

Why It's Good: Redux DevTools, predictable state updates, enforced patterns, great for teams.

Why It's Annoying: Still more boilerplate than the alternatives, requires Provider setup, actions/reducers split.

3. Zustand: My Personal Favorite for 2025

Zustand is what Redux should have been from the start. No Provider, no actions, no reducers. Just a store hook that works.

Best for: 90% of apps, rapid development, developers who value their sanity

Not ideal for: When you NEED Redux DevTools integration (though Zustand has devtools too)

// store.ts
import { create } from 'zustand';

type CartStore = {
  items: CartItem[];
  total: number;
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
};

const useCartStore = create<CartStore>((set) => ({
  items: [],
  total: 0,
  addItem: (item) => set((state) => ({
    items: [...state.items, item],
    total: state.total + item.price
  })),
  removeItem: (id) => set((state) => {
    const items = state.items.filter(item => item.id !== id);
    const total = items.reduce((sum, item) => sum + item.price, 0);
    return { items, total };
  }),
}));

// Component Usage - No Provider needed!
function Cart() {
  // Only subscribe to what you need
  const items = useCartStore((state) => state.items);
  const removeItem = useCartStore((state) => state.removeItem);

  return (
    <View>
      {items.map(item => (
        <View key={item.id}>
          <Text>{item.name}</Text>
          <Button onPress={() => removeItem(item.id)} title="Remove" />
        </View>
      ))}
    </View>
  );
}

Why It Wins:

  • No Provider wrapper
  • Works outside React components
  • Tiny bundle size (2KB)
  • Selector-based re-renders (only update when your slice changes)
  • Can use it in Vanilla JS if needed

4. MobX: When You Want OOP Magic

MobX uses observables. Change a property, and boom - components observing it re-render automatically. It's like having a house girl who knows what needs cleaning without being told.

Best for: Complex domain models, OOP fans, when you want "reactive" programming

Watch out for: Debugging can be tricky, magic is great until it's not

// store.ts
import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

class CartStore {
  items = [];

  constructor() {
    makeAutoObservable(this);
  }

  get total() {
    return this.items.reduce((sum, item) => sum + item.price, 0);
  }

  addItem(item: CartItem) {
    this.items.push(item);
  }

  removeItem(id: string) {
    this.items = this.items.filter(item => item.id !== id);
  }
}

const cartStore = new CartStore();

// Component Usage - wrap with observer
const Cart = observer(() => {
  return (
    <View>
      <Text>Total: {cartStore.total}</Text>
      {cartStore.items.map(item => (
        <View key={item.id}>
          <Text>{item.name}</Text>
          <Button onPress={() => cartStore.removeItem(item.id)} title="Remove" />
        </View>
      ))}
    </View>
  );
});

5. Jotai: Atomic State for Complex UIs

Recoil showed us atoms, but Jotai perfected them. Each piece of state is an "atom". Components subscribe to atoms. No unnecessary re-renders.

Best for: Complex dashboards, canvas apps, derived/computed state

Overkill for: Simple CRUD apps

// atoms.ts
import { atom, useAtom } from 'jotai';

const cartItemsAtom = atom<CartItem[]>([]);
const cartTotalAtom = atom((get) => {
  const items = get(cartItemsAtom);
  return items.reduce((sum, item) => sum + item.price, 0);
});

// Component Usage
function CartSummary() {
  const [total] = useAtom(cartTotalAtom);
  return <Text>Total: KSh {total}</Text>;
}

function CartItems() {
  const [items, setItems] = useAtom(cartItemsAtom);

  const removeItem = (id: string) => {
    setItems(items.filter(item => item.id !== id));
  };

  return (
    <View>
      {items.map(item => (
        <View key={item.id}>
          <Text>{item.name}</Text>
          <Button onPress={() => removeItem(item.id)} title="Remove" />
        </View>
      ))}
    </View>
  );
}

Performance: The Real Test

Here's the truth nobody tells you: Most performance problems aren't caused by your state manager.

They're caused by:

  1. Not using selectors (subscribing to entire state object)
  2. Not memoizing expensive computations
  3. Putting server data in client state (use TanStack Query instead)

The Golden Rule: Only subscribe to what you need.

// ❌ BAD: Re-renders on ANY state change
const state = useStore();
const user = state.user;

// ✅ GOOD: Only re-renders when user changes
const user = useStore((state) => state.user);

Persisting State: Don't Lose My Cart!

In React Native, use react-native-mmkv for persistence. It's way faster than AsyncStorage.

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { MMKV } from 'react-native-mmkv';

const storage = new MMKV();

const mmkvStorage = {
  setItem: (name: string, value: string) => {
    return storage.set(name, value);
  },
  getItem: (name: string) => {
    const value = storage.getString(name);
    return value ?? null;
  },
  removeItem: (name: string) => {
    return storage.delete(name);
  },
};

export const useStore = create(
  persist(
    (set) => ({
      cart: [],
      addToCart: (item) => set((state) => ({ cart: [...state.cart, item] })),
    }),
    {
      name: 'cart-storage',
      storage: createJSONStorage(() => mmkvStorage),
    }
  )
);

The Biggest Mistake: Mixing Client & Server State

Don't store API data in Redux/Zustand/whatever.

Use TanStack Query (React Query) for server state:

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

function Products() {
  const { data, isLoading } = useQuery({
    queryKey: ['products'],
    queryFn: () => fetch('/api/products').then(res => res.json()),
  });

  const addToCart = useMutation({
    mutationFn: (productId: string) =>
      fetch(`/api/cart/add`, { method: 'POST', body: JSON.stringify({ productId }) }),
  });

  if (isLoading) return <Text>Loading...</Text>;

  return (
    <View>
      {data.products.map(product => (
        <Button
          key={product.id}
          onPress={() => addToCart.mutate(product.id)}
          title={product.name}
        />
      ))}
    </View>
  );
}

TanStack Query handles loading states, caching, refetching, and more. Your state manager should only handle UI state (modals, filters, form drafts).

The Verdict

LibraryBundle SizeLearning CurveDevToolsBest For
Zustand2KBEasyGoodMost apps
Redux Toolkit11KBModerateExcellentEnterprise
Context API0KBEasyBasicThemes, Auth
Jotai3KBModerateGoodComplex UIs
MobX16KBModerateGoodOOP lovers

My 2025 Recommendation:

  • Start with Zustand for client state
  • Use TanStack Query for server state
  • Use Context ONLY for dependency injection (themes, i18n)
  • Upgrade to Redux Toolkit only if your team is huge or you need strict patterns

Remember: The best state manager is the one that lets you ship features, not the one with the fanciest documentation.

State management patterns apply across platforms - whether you're building Progressive Web Apps or deciding between mobile-first vs web-first.

For native Android development, understand Kotlin coroutines which handle async state differently than React patterns. And always prioritize user experience over clever state architecture.

May your state always be predictable!