Technical
React State Management Without Redux
Redux was the right answer in 2016. In 2025, most React apps do not need it. The built-in hooks plus a few small libraries handle 95% of real state needs with less code. Here is the state management stack I actually use.
Local State First
useState and useReducer handle component-local state. If a piece of state is only read by one component tree branch, it lives in that branch. Most form state, UI toggle state, and ephemeral state fits here.
function Modal() {
const [isOpen, setIsOpen] = useState(false);
return isOpen ? <Content /> : <button onClick={() => setIsOpen(true)}>Open</button>;
}No store, no provider, no action creator. The state lives where it is used.
Context for Cross-Tree Values
Theme, current user, locale, feature flags. Things every component might need. I use React Context for these:
const UserContext = createContext<User | null>(null);
function App() {
const [user, setUser] = useState<User | null>(null);
return <UserContext.Provider value={user}>...</UserContext.Provider>;
}Context has a performance caveat: every consumer re-renders when the value changes. For slow consumers, I split the Context into smaller ones. One for the user, one for the theme.
Server State Belongs in a Cache
For data fetched from an API, I use TanStack Query (formerly React Query). It handles caching, refetching, optimistic updates, and loading states with near-zero code:
const { data, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});Server state is not the same as client state. Treating it the same in Redux led to hundreds of lines of boilerplate for what is really 'fetch this, cache it, show loading spinner.'
Zustand for Global Client State
When I genuinely need global state that is not server data, I reach for Zustand. Three lines of setup, no boilerplate:
import { create } from 'zustand';
const useCart = create((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
}));This replaces 40 lines of Redux for the same behavior. No actions, no reducers, no types to wire up.
When Redux Actually Helps
I still reach for Redux when the app has complex state transitions that benefit from time-travel debugging, or when the team is already fluent in Redux patterns. That is a smaller set of apps than people think.
The Decision Tree
- Is it local? Use
useState. - Is it cross-cutting? Use Context.
- Does it come from an API? Use TanStack Query.
- Is it global client state? Use Zustand.
- Is it complex state machine logic? Consider XState.
Redux only shows up for me when the team votes for it. Otherwise, the lighter-weight options ship faster and break less.
See the TanStack Query documentation and the Zustand documentation for the two libraries that replace most Redux use cases.
RELATED READING
The Consulting Shift I Am Making In Year Two
After a year of writing and building, my consulting practice is changing shape. Shorter engagements. Sharper outcomes.
ReadThe Frontend Shift: Shipping Less JavaScript In Year Two
A year ago I reached for Next.js for everything. This year I often reach for nothing.
ReadThe Serverless Lesson I Would Write On A Sticky Note
After a year of shipping serverless projects, one rule explains most of the wins and all of the losses.
Read