State Management in 2023: Beyond Redux
Daniel Lawal
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:
- Component State Era: Using React's built-in useState and class component state
- Redux Dominance: Centralized state management with actions, reducers, and a single store
- Context API Renaissance: React's improved Context API offering simpler solutions for many use cases
- 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:
- Team familiarity: What does your team already know?
- Project size: Smaller projects may not need complex solutions
- State complexity: How interconnected is your state?
- Performance needs: Are there frequent updates to large state objects?
- 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!
3 Comments
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.
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?
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.
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.