Skip to main content
website logo savvydev
State Management Beyond Redux: Modern Patterns for React Apps

State Management Beyond Redux: Modern Patterns for React Apps

Discover modern state management solutions that are simpler, more performant, and easier to use than Redux. Learn when to use Context, Zustand, Jotai, and other modern alternatives.

React State Management Redux Zustand Context API Frontend

Redux was once the go-to solution for React state management, but the ecosystem has evolved dramatically. As a senior developer, I’ve seen teams struggle with Redux’s complexity when simpler solutions would work better. Let’s explore modern state management patterns that are more intuitive, performant, and maintainable.

Table of Contents

Why Move Beyond Redux?

Redux has several limitations that modern alternatives address:

  • Boilerplate overload - Actions, reducers, selectors, middleware
  • Complex setup - Store configuration, dev tools, middleware
  • Performance overhead - Unnecessary re-renders and complexity
  • Learning curve - Hard for junior developers to grasp
  • Bundle size - Large footprint for simple state needs

Modern solutions offer:

  • Less boilerplate - Write less code, do more
  • Better performance - Automatic optimizations
  • Easier learning curve - Intuitive APIs
  • Smaller bundles - Tree-shakable and lightweight

React Context API

Basic Context Pattern

// ❌ BAD: Prop drilling
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");

  return (
    <div>
      <Header user={user} theme={theme} />
      <Main user={user} theme={theme} />
      <Footer user={user} theme={theme} />
    </div>
  );
}

// ✅ GOOD: Context API
const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");

  const value = {
    user,
    setUser,
    theme,
    setTheme,
  };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

function useApp() {
  const context = useContext(AppContext);
  if (!context) {
    throw new Error("useApp must be used within AppProvider");
  }
  return context;
}

function App() {
  return (
    <AppProvider>
      <Header />
      <Main />
      <Footer />
    </AppProvider>
  );
}

Optimized Context with Memoization

// Prevent unnecessary re-renders
const AppContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");

  // Memoize context value to prevent unnecessary re-renders
  const value = useMemo(
    () => ({
      user,
      setUser,
      theme,
      setTheme,
    }),
    [user, theme],
  );

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

// Split contexts for better performance
const UserContext = createContext();
const ThemeContext = createContext();

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState("light");

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <ThemeContext.Provider value={{ theme, setTheme }}>
        {children}
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

Modern State Management Libraries

Zustand

Zustand is a small, fast, and scalable state management solution.

import { create } from "zustand";

// Simple store
const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

// Usage
function Counter() {
  const { count, increment, decrement, reset } = useStore();

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

Advanced Zustand Patterns

// TypeScript support
interface UserStore {
  user: User | null;
  loading: boolean;
  error: string | null;
  fetchUser: (id: string) => Promise<void>;
  updateUser: (user: User) => void;
  logout: () => void;
}

const useUserStore = create<UserStore>((set, get) => ({
  user: null,
  loading: false,
  error: null,

  fetchUser: async (id: string) => {
    set({ loading: true, error: null });
    try {
      const user = await api.getUser(id);
      set({ user, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },

  updateUser: (user: User) => {
    set({ user });
  },

  logout: () => {
    set({ user: null, error: null });
  },
}));

// Selectors for performance
function UserProfile() {
  const user = useUserStore((state) => state.user);
  const loading = useUserStore((state) => state.loading);
  const fetchUser = useUserStore((state) => state.fetchUser);

  // Only re-renders when user or loading changes
  if (loading) return <div>Loading...</div>;
  if (!user) return <div>No user</div>;

  return <div>Hello, {user.name}!</div>;
}

Jotai

Jotai is an atomic state management library that’s perfect for React.

import { atom, useAtom } from "jotai";

// Atomic state
const countAtom = atom(0);
const userAtom = atom(null);
const themeAtom = atom("light");

// Derived atoms
const doubleCountAtom = atom((get) => get(countAtom) * 2);
const userDisplayNameAtom = atom((get) => {
  const user = get(userAtom);
  return user ? user.name : "Guest";
});

// Usage
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubleCount] = useAtom(doubleCountAtom);

  return (
    <div>
      <p>Count: {count}</p>
      <p>Double: {doubleCount}</p>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

Valtio

Valtio makes state management feel like regular JavaScript objects.

import { proxy, useSnapshot } from "valtio";

// Create a proxy state
const state = proxy({
  count: 0,
  user: null,
  theme: "light",

  increment() {
    this.count++;
  },

  setUser(user) {
    this.user = user;
  },

  toggleTheme() {
    this.theme = this.theme === "light" ? "dark" : "light";
  },
});

// Usage in components
function Counter() {
  const snap = useSnapshot(state);

  return (
    <div>
      <p>Count: {snap.count}</p>
      <button onClick={() => state.increment()}>+</button>
    </div>
  );
}

When to Use Each Solution

Context API

Use when:

  • Simple global state (theme, user, language)
  • Small to medium applications
  • Team prefers built-in React solutions
  • Minimal state updates

Avoid when:

  • Complex state logic
  • Frequent state updates
  • Performance is critical

Zustand

Use when:

  • Need simple, fast state management
  • Want minimal boilerplate
  • Building medium to large applications
  • Need TypeScript support

Avoid when:

  • Very simple state (Context API is better)
  • Need complex middleware

Jotai

Use when:

  • Building with React 18+ features
  • Want atomic state management
  • Need fine-grained reactivity
  • Building complex UIs

Avoid when:

  • Simple state needs
  • Team prefers traditional stores

Valtio

Use when:

  • Want mutable state syntax
  • Building prototypes quickly
  • Team prefers object-oriented patterns
  • Need simple state updates

Avoid when:

  • Need strict immutability
  • Building large, complex applications

Migration Strategies

From Redux to Zustand

// Redux store
const initialState = {
  user: null,
  posts: [],
  loading: false,
  error: null,
};

const userSlice = createSlice({
  name: "user",
  initialState,
  reducers: {
    setUser: (state, action) => {
      state.user = action.payload;
    },
    setPosts: (state, action) => {
      state.posts = action.payload;
    },
    setLoading: (state, action) => {
      state.loading = action.payload;
    },
    setError: (state, action) => {
      state.error = action.payload;
    },
  },
});

// Zustand equivalent
const useStore = create((set) => ({
  user: null,
  posts: [],
  loading: false,
  error: null,

  setUser: (user) => set({ user }),
  setPosts: (posts) => set({ posts }),
  setLoading: (loading) => set({ loading }),
  setError: (error) => set({ error }),
}));

From Context to Zustand

// Context API
const UserContext = createContext();

function UserProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  const fetchUser = async (id) => {
    setLoading(true);
    const user = await api.getUser(id);
    setUser(user);
    setLoading(false);
  };

  return (
    <UserContext.Provider value={{ user, loading, fetchUser }}>
      {children}
    </UserContext.Provider>
  );
}

// Zustand equivalent
const useUserStore = create((set) => ({
  user: null,
  loading: false,

  fetchUser: async (id) => {
    set({ loading: true });
    const user = await api.getUser(id);
    set({ user, loading: false });
  },
}));

Performance Considerations

Context API Performance

// ❌ BAD: Large context value
const AppContext = createContext();

function AppProvider({ children }) {
  const [state, setState] = useState({
    user: null,
    posts: [],
    comments: [],
    settings: {},
    notifications: [],
    // ... many more
  });

  return (
    <AppContext.Provider value={{ state, setState }}>
      {children}
    </AppContext.Provider>
  );
}

// ✅ GOOD: Split contexts
const UserContext = createContext();
const PostsContext = createContext();
const SettingsContext = createContext();

// ✅ BETTER: Use Zustand for complex state
const useStore = create((set) => ({
  user: null,
  posts: [],
  settings: {},
  // ... all state in one place
}));

Zustand Performance

// Selective subscriptions
function UserProfile() {
  // Only re-renders when user changes
  const user = useStore((state) => state.user);

  // Only re-renders when posts change
  const posts = useStore((state) => state.posts);

  return (
    <div>
      <h1>{user?.name}</h1>
      <PostList posts={posts} />
    </div>
  );
}

// Shallow comparison
const useStore = create((set) => ({
  user: null,
  posts: [],
  setUser: (user) => set({ user }, true), // true = shallow comparison
}));

Real-World Examples

E-commerce Cart with Zustand

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

  addItem: (product) => {
    const { items } = get();
    const existingItem = items.find((item) => item.id === product.id);

    if (existingItem) {
      set({
        items: items.map((item) =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item,
        ),
      });
    } else {
      set({
        items: [...items, { ...product, quantity: 1 }],
      });
    }

    // Recalculate total
    get().calculateTotal();
  },

  removeItem: (productId) => {
    const { items } = get();
    set({
      items: items.filter((item) => item.id !== productId),
    });
    get().calculateTotal();
  },

  updateQuantity: (productId, quantity) => {
    const { items } = get();
    set({
      items: items.map((item) =>
        item.id === productId
          ? { ...item, quantity: Math.max(0, quantity) }
          : item,
      ),
    });
    get().calculateTotal();
  },

  calculateTotal: () => {
    const { items } = get();
    const total = items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    );
    set({ total });
  },

  clearCart: () => set({ items: [], total: 0 }),
}));

// Usage
function Cart() {
  const { items, total, addItem, removeItem, updateQuantity } = useCartStore();

  return (
    <div>
      {items.map((item) => (
        <CartItem
          key={item.id}
          item={item}
          onRemove={() => removeItem(item.id)}
          onUpdateQuantity={(quantity) => updateQuantity(item.id, quantity)}
        />
      ))}
      <div>Total: ${total}</div>
    </div>
  );
}

Theme Management with Jotai

import { atom, useAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";

// Persistent theme atom
const themeAtom = atomWithStorage("theme", "light");

// Derived atoms
const isDarkAtom = atom((get) => get(themeAtom) === "dark");
const themeColorsAtom = atom((get) => {
  const theme = get(themeAtom);
  return theme === "dark" ? darkColors : lightColors;
});

// Actions
const toggleThemeAtom = atom(null, (get, set) => {
  const currentTheme = get(themeAtom);
  set(themeAtom, currentTheme === "light" ? "dark" : "light");
});

// Usage
function ThemeToggle() {
  const [isDark] = useAtom(isDarkAtom);
  const [, toggleTheme] = useAtom(toggleThemeAtom);

  return <button onClick={toggleTheme}>{isDark ? "☀️" : "🌙"}</button>;
}

Best Practices

1. Choose the Right Tool

// Simple global state → Context API
const ThemeContext = createContext();

// Complex state with actions → Zustand
const useStore = create((set) => ({
  // complex state and actions
}));

// Atomic state → Jotai
const countAtom = atom(0);

// Mutable state → Valtio
const state = proxy({ count: 0 });

2. Structure Your Stores

// Organize by feature
const useUserStore = create((set) => ({
  // User-related state
}));

const useCartStore = create((set) => ({
  // Cart-related state
}));

const useSettingsStore = create((set) => ({
  // Settings-related state
}));

3. Use Selectors for Performance

// ❌ BAD: Subscribe to entire store
function UserProfile() {
  const store = useStore(); // Re-renders on any change
  return <div>{store.user?.name}</div>;
}

// ✅ GOOD: Use selectors
function UserProfile() {
  const user = useStore((state) => state.user); // Only re-renders when user changes
  return <div>{user?.name}</div>;
}

Next Steps

1. Advanced Patterns

2. Performance Optimization

3. Testing State Management

import { renderHook, act } from "@testing-library/react";
import { useStore } from "./store";

test("increments count", () => {
  const { result } = renderHook(() => useStore());

  act(() => {
    result.current.increment();
  });

  expect(result.current.count).toBe(1);
});

Conclusion

Modern state management solutions offer significant advantages over Redux for most applications. The key is choosing the right tool for your specific needs.

Key Takeaways for Junior Developers:

  1. Start simple - Use Context API for basic global state
  2. Choose the right tool - Each solution has its strengths
  3. Consider performance - Use selectors and memoization
  4. Structure your state - Organize by features
  5. Test your state - Ensure reliability and maintainability

Remember: State management should make your life easier, not harder. Choose solutions that reduce complexity and improve developer experience.

Related Articles:

Start building better state management today! 🚀