<Firecoder />
#React#State Management#Redux#Zustand#React Query

State Management in 2023: Beyond Redux

D

Daniel Lawal

February 15, 2023
9 min read
3,876 reads
State Management in 2023: Beyond Redux

State Management in 2023: Beyond Redux

For years, Redux has been the go-to state management solution for React applications. However, the landscape has evolved significantly, with new libraries and patterns emerging to address different state management needs. In this article, we'll explore modern alternatives and when to use each approach.

The Evolution of State Management

State management has gone through several phases in the React ecosystem:

  1. Component State Era: Using React's built-in useState and class component state
  2. Redux Dominance: Centralized state management with actions, reducers, and a single store
  3. Context API Renaissance: React's improved Context API offering simpler solutions for many use cases
  4. Specialized Libraries: Purpose-built libraries for specific state management needs

When to Use Different State Management Approaches

Before diving into specific libraries, it's important to understand that there's no one-size-fits-all solution. Here's a framework for choosing the right approach:

Local Component State

Best for: UI state that belongs to a single component

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

Pros:

  • Simple and built into React
  • No additional dependencies
  • Component remains self-contained

Cons:

  • Doesn't scale to shared state
  • Can lead to prop drilling

React Context + useReducer

Best for: Shared state within a specific feature or section of your app

// Create context
const TodoContext = createContext();

// Create reducer
function todoReducer(state, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: Date.now(), text: action.payload, completed: false }];
    case 'TOGGLE_TODO':
      return state.map(todo => 
        todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo
      );
    default:
      return state;
  }
}

// Provider component
function TodoProvider({ children }) {
  const [todos, dispatch] = useReducer(todoReducer, []);
  
  return (
    <TodoContext.Provider value={{ todos, dispatch }}>
      {children}
    </TodoContext.Provider>
  );
}

// Consumer component
function TodoList() {
  const { todos, dispatch } = useContext(TodoContext);
  
  return (
    <ul>
      {todos.map(todo => (
        <li 
          key={todo.id}
          style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
          onClick={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
        >
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

Pros:

  • No external dependencies
  • Less boilerplate than Redux
  • Follows familiar reducer pattern

Cons:

  • Context is not optimized for high-frequency updates
  • Can lead to performance issues with large state objects
  • No built-in dev tools

Zustand

Best for: Global state with minimal boilerplate

Zustand is a small, fast state-management solution using simplified flux principles.

import create from 'zustand';

// Create store
const useStore = create(set => ({
  bears: 0,
  increasePopulation: () => set(state => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}));

// Component
function BearCounter() {
  const bears = useStore(state => state.bears);
  const increasePopulation = useStore(state => state.increasePopulation);
  
  return (
    <div>
      <h1>{bears} bears around here...</h1>
      <button onClick={increasePopulation}>Add a bear</button>
    </div>
  );
}

Pros:

  • Minimal boilerplate
  • No providers needed
  • Great TypeScript support
  • Supports middleware (including Redux dev tools)

Cons:

  • Less established ecosystem than Redux
  • Less structured than Redux (which can be good or bad)

Jotai

Best for: Atomic state management with interdependent atoms

Jotai takes an atomic approach to state management, inspired by Recoil.

import { atom, useAtom } from 'jotai';

// Define atoms
const countAtom = atom(0);
const doubleCountAtom = atom(get => get(countAtom) * 2);

// Component
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubleCount] = useAtom(doubleCountAtom);
  
  return (
    <div>
      <h1>Count: {count}</h1>
      <h2>Double: {doubleCount}</h2>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

Pros:

  • Atomic updates minimize re-renders
  • Derived state is simple to express
  • Works well with React Suspense
  • No providers needed for most use cases

Cons:

  • Newer library with evolving API
  • Different mental model than traditional flux

React Query / SWR

Best for: Server state management

React Query and SWR are libraries focused on managing server state.

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

// Fetch todos
function Todos() {
  const queryClient = useQueryClient();
  
  // Query
  const { data: todos, isLoading } = useQuery('todos', fetchTodos);
  
  // Mutation
  const mutation = useMutation(newTodo => addTodo(newTodo), {
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries('todos');
    },
  });
  
  if (isLoading) return <div>Loading...</div>;
  
  return (
    <div>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      <button onClick={() => mutation.mutate({ title: 'New Todo' })}>
        Add Todo
      </button>
    </div>
  );
}

Pros:

  • Built-in caching
  • Automatic refetching
  • Loading/error states
  • Optimistic updates
  • Pagination and infinite scroll support

Cons:

  • Focused on server state, not ideal for client-only state
  • Learning curve for advanced features

Comparing Approaches: A Real-World Example

Let's compare how different libraries would handle a common scenario: a shopping cart in an e-commerce application.

Requirements:

  • Add/remove items from cart
  • Update quantities
  • Calculate totals
  • Persist cart between sessions
  • Synchronize with server

Redux Implementation

// Actions
const ADD_TO_CART = 'ADD_TO_CART';
const REMOVE_FROM_CART = 'REMOVE_FROM_CART';
const UPDATE_QUANTITY = 'UPDATE_QUANTITY';

// Action creators
const addToCart = (product) => ({ type: ADD_TO_CART, payload: product });
const removeFromCart = (productId) => ({ type: REMOVE_FROM_CART, payload: productId });
const updateQuantity = (productId, quantity) => ({ 
  type: UPDATE_QUANTITY, 
  payload: { productId, quantity } 
});

// Reducer
const initialState = {
  items: [],
  total: 0
};

function cartReducer(state = initialState, action) {
  switch (action.type) {
    case ADD_TO_CART:
      const existingItem = state.items.find(item => item.id === action.payload.id);
      
      if (existingItem) {
        return {
          ...state,
          items: state.items.map(item => 
            item.id === action.payload.id 
              ? { ...item, quantity: item.quantity + 1 } 
              : item
          ),
          total: state.total + action.payload.price
        };
      }
      
      return {
        ...state,
        items: [...state.items, { ...action.payload, quantity: 1 }],
        total: state.total + action.payload.price
      };
      
    case REMOVE_FROM_CART:
      const itemToRemove = state.items.find(item => item.id === action.payload);
      
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
        total: state.total - (itemToRemove.price * itemToRemove.quantity)
      };
      
    case UPDATE_QUANTITY:
      const { productId, quantity } = action.payload;
      const item = state.items.find(item => item.id === productId);
      const quantityDiff = quantity - item.quantity;
      
      return {
        ...state,
        items: state.items.map(item => 
          item.id === productId ? { ...item, quantity } : item
        ),
        total: state.total + (item.price * quantityDiff)
      };
      
    default:
      return state;
  }
}

Zustand Implementation

import create from 'zustand';
import { persist } from 'zustand/middleware';

const useCartStore = create(
  persist(
    (set, get) => ({
      items: [],
      total: 0,
      
      addToCart: (product) => {
        const items = get().items;
        const existingItem = items.find(item => item.id === product.id);
        
        if (existingItem) {
          set(state => ({
            items: state.items.map(item => 
              item.id === product.id 
                ? { ...item, quantity: item.quantity + 1 } 
                : item
            ),
            total: state.total + product.price
          }));
        } else {
          set(state => ({
            items: [...state.items, { ...product, quantity: 1 }],
            total: state.total + product.price
          }));
        }
      },
      
      removeFromCart: (productId) => {
        const items = get().items;
        const itemToRemove = items.find(item => item.id === productId);
        
        set(state => ({
          items: state.items.filter(item => item.id !== productId),
          total: state.total - (itemToRemove.price * itemToRemove.quantity)
        }));
      },
      
      updateQuantity: (productId, quantity) => {
        const items = get().items;
        const item = items.find(item => item.id === productId);
        const quantityDiff = quantity - item.quantity;
        
        set(state => ({
          items: state.items.map(item => 
            item.id === productId ? { ...item, quantity } : item
          ),
          total: state.total + (item.price * quantityDiff)
        }));
      }
    }),
    {
      name: 'cart-storage', // unique name for localStorage
    }
  )
);

React Query + Zustand Implementation

For a more complete solution that handles both client and server state:

import create from 'zustand';
import { persist } from 'zustand/middleware';
import { useQuery, useMutation, useQueryClient } from 'react-query';

// API functions
const fetchCart = async () => {
  const response = await fetch('/api/cart');
  if (!response.ok) throw new Error('Network response was not ok');
  return response.json();
};

const updateCartOnServer = async (cart) => {
  const response = await fetch('/api/cart', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(cart),
  });
  if (!response.ok) throw new Error('Network response was not ok');
  return response.json();
};

// Local state with Zustand
const useCartStore = create(
  persist(
    (set, get) => ({
      items: [],
      total: 0,
      
      // Same methods as before...
      addToCart: (product) => { /* ... */ },
      removeFromCart: (productId) => { /* ... */ },
      updateQuantity: (productId, quantity) => { /* ... */ },
      
      // New method to sync with server data
      syncWithServer: (serverCart) => {
        set({
          items: serverCart.items,
          total: serverCart.total
        });
      }
    }),
    {
      name: 'cart-storage',
    }
  )
);

// Cart component using both React Query and Zustand
function Cart() {
  const queryClient = useQueryClient();
  const { items, total, addToCart, removeFromCart, updateQuantity, syncWithServer } = useCartStore();
  
  // Fetch cart from server
  const { isLoading } = useQuery('cart', fetchCart, {
    onSuccess: (data) => {
      syncWithServer(data);
    },
    // Don't refetch on window focus to avoid overwriting local changes
    refetchOnWindowFocus: false
  });
  
  // Sync cart to server
  const mutation = useMutation(updateCartOnServer, {
    onSuccess: () => {
      queryClient.invalidateQueries('cart');
    }
  });
  
  // Sync to server whenever cart changes
  useEffect(() => {
    if (items.length > 0) {
      mutation.mutate({ items, total });
    }
  }, [items, total]);
  
  if (isLoading) return <div>Loading cart...</div>;
  
  return (
    <div>
      {/* Cart UI */}
    </div>
  );
}

Making the Right Choice for Your Project

When selecting a state management solution, consider:

  1. Team familiarity: What does your team already know?
  2. Project size: Smaller projects may not need complex solutions
  3. State complexity: How interconnected is your state?
  4. Performance needs: Are there frequent updates to large state objects?
  5. Server vs. client state: Different tools excel at different types of state

Conclusion

The state management landscape has evolved significantly beyond Redux. While Redux remains a solid choice for many applications, alternatives like Zustand, Jotai, and React Query offer compelling benefits for specific use cases.

Rather than choosing a single library for all state management needs, modern React applications often benefit from a hybrid approach:

  • React Query or SWR for server state
  • Zustand or Jotai for global UI state
  • React Context for feature-specific state
  • Component state for local UI concerns

By matching the right tool to each state management need, you can build applications that are both performant and maintainable.

What state management solutions are you using in your projects? Share your experiences in the comments below!

Show comments

Leave a comment

3 Comments

D
David ParkFebruary 16, 2023

Great breakdown of the different options! I've been using React Query + Context API for most of my projects lately and it's been a game changer for separating server and client state concerns.

S
Sophia MartinezFebruary 17, 2023

I've been hesitant to move away from Redux because of how established it is, but Zustand looks really promising. Has anyone here used it in a large production application?

D
Daniel LawalFebruary 17, 2023

I've used Zustand in a few medium to large applications and it's scaled quite well. The key advantage is that it maintains the familiar concepts from Redux (actions, state updates) but with much less boilerplate. For large apps, I recommend organizing your store into slices using the concept of 'domains' to keep things manageable. The Redux DevTools integration is also excellent for debugging.

T
Thomas WrightFebruary 20, 2023

One thing I think is worth mentioning is that Redux Toolkit has significantly reduced the boilerplate issues that plagued classic Redux. It's still more verbose than Zustand, but the gap isn't as wide as it used to be.

HomeProjectsExperienceBlogGamesAbout