Redux dominated React state management for years, but the ecosystem has evolved dramatically. In 2024, developers have access to a rich ecosystem of state management solutions, each optimized for different use cases. Let's explore the modern alternatives that are changing how we think about state and when to use each one.
The Problem with Traditional Redux
Redux is powerful but comes with significant boilerplate. For many applications, it's simply overkill. Action creators, reducers, selectors, middleware – the cognitive overhead adds up quickly. While Redux Toolkit has simplified much of this, the fundamental pattern still requires more ceremony than simpler alternatives.
The typical Redux setup involves creating action types, action creators, reducers, configuring the store with middleware, and connecting components. This ceremony made sense for large applications with complex state, but for most projects, it introduces unnecessary complexity.
40%Less code with Zustand
3xFaster setup time
0Boilerplate required
2KBBundle size (Zustand)
Enter Zustand: Simplicity Meets Power
Zustand offers Redux-like capabilities with a fraction of the code. Created by the team behind React Spring, it's designed to be minimal, flexible, and powerful. Here's a complete store in just a few lines:
javascript
import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'
const useStore = create(
devtools(
persist(
(set, get) => ({
// State
user: null,
cart: [],
isLoading: false,
// Actions
login: (user) => set({ user }, false, 'login'),
logout: () => set({ user: null, cart: [] }, false, 'logout'),
addToCart: (item) => set(
(state) => ({ cart: [...state.cart, item] }),
false,
'addToCart'
),
removeFromCart: (id) => set(
(state) => ({
cart: state.cart.filter(item => item.id !== id)
}),
false,
'removeFromCart'
),
// Computed values with selectors
get cartTotal() {
return get().cart.reduce((sum, item) => sum + item.price, 0)
},
get cartCount() {
return get().cart.length
}
}),
{ name: 'app-storage' }
)
)
)Using it in components is equally simple. Zustand automatically optimizes re-renders by only updating components that use the specific pieces of state that changed:
jsx
function CartButton() {
// Only re-renders when cart changes
const cart = useStore(state => state.cart)
const cartTotal = useStore(state => state.cartTotal)
return (
<button className="cart-button">
<CartIcon />
<span>{cart.length} items</span>
<span>${cartTotal.toFixed(2)}</span>
</button>
)
}
function UserProfile() {
// Only re-renders when user changes
const user = useStore(state => state.user)
const logout = useStore(state => state.logout)
if (!user) return <LoginButton />
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<span>{user.name}</span>
<button onClick={logout}>Logout</button>
</div>
)
}Jotai: Atomic State Management
Jotai takes a different approach with atomic state. Instead of a single store, you create atoms that can be composed together. This is perfect for state that's distributed across your component tree.
javascript
import { atom, useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
// Primitive atoms
const countAtom = atom(0)
const textAtom = atom('')
// Derived atoms (computed values)
const doubleCountAtom = atom((get) => get(countAtom) * 2)
// Async atoms for data fetching
const userAtom = atom(async () => {
const response = await fetch('/api/user')
return response.json()
})
// Persisted atoms
const themeAtom = atomWithStorage('theme', 'dark')
// Writable derived atoms
const uppercaseTextAtom = atom(
(get) => get(textAtom).toUpperCase(),
(get, set, newValue) => set(textAtom, newValue.toLowerCase())
)The atomic approach means each piece of state is independent. Components subscribe to specific atoms and only re-render when those atoms change. This granular reactivity is excellent for performance.
When Context API is Enough
For simple state that doesn't change often (themes, auth, locale), Context API is perfect. The key is avoiding frequent updates to prevent unnecessary re-renders. Use context for 'ambient' state that many components need but rarely changes.
jsx
const ThemeContext = createContext()
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('dark')
// Memoize to prevent unnecessary re-renders
const value = useMemo(() => ({
theme,
toggle: () => setTheme(t => t === 'dark' ? 'light' : 'dark'),
setTheme
}), [theme])
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
// Custom hook for type safety and convenience
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}Server State vs Client State
One of the biggest insights in modern state management is the distinction between server state and client state. Server state is data that lives on your server and needs to be synchronized with your frontend. Client state is local to your application—UI state, form inputs, etc.
- Server State (TanStack Query, SWR): API responses, database data, anything from your backend
- Client State (Zustand, Jotai): UI state, form state, modal visibility, theme preferences
- URL State (React Router): Current page, filters, search queries
- Form State (React Hook Form): Controlled inputs, validation, submission
jsx
// Combining TanStack Query for server state with Zustand for client state
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useStore } from './store'
function ProductList() {
// Server state - fetched and cached
const { data: products, isLoading } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts
})
// Client state - local UI state
const { selectedCategory, setSelectedCategory } = useStore()
const filtered = products?.filter(p =>
!selectedCategory || p.category === selectedCategory
)
if (isLoading) return <Skeleton />
return (
<>
<CategoryFilter
value={selectedCategory}
onChange={setSelectedCategory}
/>
<ProductGrid products={filtered} />
</>
)
}Making the Right Choice
- Context API: Auth, theme, locale – things that rarely change
- Zustand: Most application state, when you need simplicity with DevTools
- Jotai: When you need atomic, bottom-up state composition
- TanStack Query: All server state, API caching, background refetching
- Redux Toolkit: Large teams, complex requirements, strict patterns needed
- Recoil: Facebook-scale applications with complex derived state
The best state management solution is the simplest one that meets your needs. Start with React's built-in tools (useState, useContext), add TanStack Query for server state, and reach for Zustand or Jotai only when local state becomes complex. Start small and scale up only when necessary.
ZustandBest for most apps
TanStackBest for server state
ContextBest for themes
JotaiBest for atomic state