I keep opening React codebases that look like this. A single Redux store managing API responses, UI toggles, form inputs, filter selections, and URL parameters all at once. Thousands of lines of reducers, actions, and selectors for state that has fundamentally different needs.
Then someone asks "why is our app so slow" and "why does adding a feature take three weeks."
The problem is not Redux. The problem is treating all state the same.
Here is a simple architecture that fixes this. Every piece of state in your app belongs in one of four buckets. Each bucket has different characteristics and a different ideal tool.
Bucket 1. Server State
This is data from your backend. Users, products, orders, comments. It has an authoritative source, it can go stale, and multiple clients might mutate it simultaneously.
Stop managing this in Redux. Use React Query.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from './api'
function useUser(userId) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => api.getUser(userId),
staleTime: 5 * 60 * 1000,
})
}
function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data) => api.updateUser(data),
onSuccess: (updatedUser) => {
queryClient.setQueryData(
['user', updatedUser.id],
updatedUser
)
},
})
}
You get caching, background refetching, optimistic updates, and stale data management for free. Replicating this in Redux takes hundreds of lines of boilerplate.
Bucket 2. Shared Client State
Data that multiple components across your app need but has no server counterpart. Theme, shopping cart before checkout, notification count, feature flags.
Zustand handles this with almost zero boilerplate.
import { create } from 'zustand'
const useCartStore = create((set, get) => ({
items: [],
addItem: (product) => set((state) => {
const existing = state.items.find(i => i.id === product.id)
if (existing) {
return {
items: state.items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
),
}
}
return { items: [...state.items, { ...product, quantity: 1 }] }
}),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id),
})),
total: () => get().items.reduce(
(sum, i) => sum + i.price * i.quantity, 0
),
}))
No providers. No context wrappers. No action types. Just a hook.
Bucket 3. Local UI State
Which modal is open. Whether the sidebar is collapsed. What the user typed into a search input. This state belongs to one component or a small component tree.
useState is all you need.
function SearchBar({ onSearch }) {
const [query, setQuery] = useState('')
const [isFocused, setIsFocused] = useState(false)
const handleSubmit = (e) => {
e.preventDefault()
onSearch(query)
}
return (
<form onSubmit={handleSubmit}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="Search products..."
/>
{isFocused && query.length > 0 && (
<SearchSuggestions query={query} />
)}
</form>
)
}
Do not put this in a global store. Nobody else needs it.
Bucket 4. URL State
Filters, pagination, search queries, active tabs. Anything a user should be able to bookmark or share.
The URL is your state manager.
import { useSearchParams } from 'react-router-dom'
function ProductList() {
const [searchParams, setSearchParams] = useSearchParams()
const category = searchParams.get('category') || 'all'
const sort = searchParams.get('sort') || 'newest'
const page = parseInt(searchParams.get('page') || '1')
const { data: products } = useQuery({
queryKey: ['products', { category, sort, page }],
queryFn: () => api.getProducts({ category, sort, page }),
})
const updateFilter = (key, value) => {
setSearchParams((prev) => {
prev.set(key, value)
if (key !== 'page') prev.set('page', '1')
return prev
})
}
return (
<div>
<CategoryFilter
value={category}
onChange={(v) => updateFilter('category', v)}
/>
<SortSelect
value={sort}
onChange={(v) => updateFilter('sort', v)}
/>
<ProductGrid products={products?.items} />
<Pagination
current={page}
total={products?.totalPages}
onChange={(p) => updateFilter('page', String(p))}
/>
</div>
)
}
Notice how URL state and server state work together. The URL drives the query key. Change a filter, URL updates, React Query refetches. One source of truth. No sync bugs.
The Anti-Pattern
Here is what the same filter logic looks like when someone puts URL state in Redux.
// store/filtersSlice.js
const filtersSlice = createSlice({
name: 'filters',
initialState: { category: 'all', sort: 'newest', page: 1 },
reducers: {
setCategory: (state, action) => {
state.category = action.payload
state.page = 1
},
setSort: (state, action) => {
state.sort = action.payload
state.page = 1
},
setPage: (state, action) => {
state.page = action.payload
},
},
})
// Now you need middleware to sync Redux with URL
// And a listener to sync URL back to Redux on navigation
// And you need to handle initial load from URL
// And browser back/forward buttons
// And you have two sources of truth that drift apart
// And you spend a week debugging why filters reset on refresh
Two sources of truth. Sync logic everywhere. Bugs on every browser navigation. All because state ended up in the wrong bucket.
The Rule
Before you write any state management code, ask one question. Where does this data come from?
From the server → React Query.
Shared across the app, client only → Zustand.
Local to one component → useState.
Should survive in a URL → useSearchParams.
Four buckets. Four tools. Zero confusion.
I wrote a longer piece covering component architecture, API layer design, error handling patterns, and how all of this shows up in system design interviews. You can find it at jsgurujobs.com.
Top comments (0)