TL;DR — React state evolved from setState → Redux → hooks → Zustand → React Query. The lesson: use the right tool for the right type of state.
👉 The biggest mistake developers made for years: treating server state like client state.
Introduction
Every React developer eventually hits the same frustration:
"My state is getting out of control. Components need data three levels up. API calls are scattered everywhere. Nothing stays in sync."
Over the years, the React community developed several solutions to address this problem. Some worked well. Some created new ones.
Here is the honest timeline:
this.setState — class components (2013)
↓
Redux — centralized global store (2015)
↓
Context API — stable, built-in (2018)
↓
useState / useReducer — hooks era (2019)
↓
Zustand / Jotai — lightweight global state (2019-2021)
↓
React Query / SWR — server state (2020) ✅
In this blog, we'll walk through each stage — not just what each solution is, but why it exists and what problem it was solving.
One Important Clarification
Redux was not created to replace Context API.
Redux came in 2015 — three years before Context API was stable. Developers used Redux because Context was experimental and unreliable at the time.
Redux filled a real gap. Once hooks and stable Context arrived, lighter alternatives became viable. That is the real story.
1️⃣ this.setState — The Beginning (2013)
React launched with class components and this.setState as the only way to manage state.
Example
class Counter extends React.Component {
state = { count: 0 };
increment = () => {
this.setState({ count: this.state.count + 1 });
};
render() {
return (
<div>
<p>{this.state.count}</p>
<button onClick={this.increment}>Increment</button>
</div>
);
}
}
Evaluation
this.setState worked well for:
- isolated component state
- UI toggles (open/close, show/hide)
- simple form inputs
The Problem — Prop Drilling
As apps grew, state needed to be shared across components.
<App user={user}>
<Dashboard user={user}>
<Header user={user}>
<Avatar user={user} />
</Header>
</Dashboard>
</App>
Every intermediate component had to pass props it didn't even use. Developers needed a better solution.
2️⃣ Redux — Centralized Global Store (2015)
Redux was created by Dan Abramov and Andrew Clark in 2015. The idea was simple:
A centralized store for application state. State flows in one direction. Every update is predictable.
Core Concept
Action → Reducer → Store → Component
Example
// Action
const increment = () => ({ type: 'INCREMENT' });
// Reducer
function counterReducer(state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
default:
return state;
}
}
// Component
function Counter() {
const count = useSelector(state => state.count);
const dispatch = useDispatch();
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
}
Middleware for Async — redux-thunk
const fetchUser = (id) => async (dispatch) => {
dispatch({ type: 'FETCH_USER_LOADING' });
try {
const user = await api.getUser(id);
dispatch({ type: 'FETCH_USER_SUCCESS', payload: user });
} catch (error) {
dispatch({ type: 'FETCH_USER_FAILED' });
}
};
Evaluation
Redux solved:
- prop drilling
- predictable state updates
- time-travel debugging
- shared state across the entire app
The Problem — Boilerplate Hell
// Just to fetch one user you needed ALL of this:
const FETCH_USER_LOADING = 'FETCH_USER_LOADING';
const FETCH_USER_SUCCESS = 'FETCH_USER_SUCCESS';
const FETCH_USER_FAILED = 'FETCH_USER_FAILED';
const fetchUserLoading = () => ({ type: FETCH_USER_LOADING });
const fetchUserSuccess = (user) => ({ type: FETCH_USER_SUCCESS, payload: user });
const fetchUserFailed = () => ({ type: FETCH_USER_FAILED });
function userReducer(state = {}, action) {
switch (action.type) {
case FETCH_USER_LOADING: return { ...state, loading: true };
case FETCH_USER_SUCCESS: return { ...state, loading: false, user: action.payload };
case FETCH_USER_FAILED: return { ...state, loading: false, error: true };
default: return state;
}
}
// + middleware + connect + mapStateToProps + mapDispatchToProps
Just to fetch one API endpoint. Developers wanted something simpler.
Note: Modern Redux is no longer boilerplate-heavy thanks to Redux Toolkit (RTK), which simplifies reducers, actions, and async logic significantly. However, many teams still prefer Zustand for its simplicity.
3️⃣ Context API — Built-in Solution (2018)
React 16.3 made the Context API stable. It allowed sharing state across the component tree — no external library needed.
Example
// Create context
const UserContext = createContext(null);
// Provide at the top level
function App() {
const [user, setUser] = useState(null);
return (
<UserContext.Provider value={{ user, setUser }}>
<Dashboard />
</UserContext.Provider>
);
}
// Consume anywhere in the tree
function Avatar() {
const { user } = useContext(UserContext);
return <img src={user.avatar} alt={user.name} />;
}
Evaluation
Context API works well for:
- authentication state
- theme (dark/light mode)
- language / locale
- simple global config
The Problem — Re-render Performance
Context triggers re-renders for all consumers by default when the value reference changes.
// If ANY value in UserContext changes,
// EVERY component using it re-renders
const { user, theme, language } = useContext(UserContext);
However, this can be optimized using:
- context splitting
- memoization (
React.memo,useMemo) - selectors (with libraries like
use-context-selector)
For small apps this is fine. For large apps with frequent updates — it causes serious performance problems.
4️⃣ useState / useReducer — Hooks Era (React 16.8 — 2019)
React 16.8 introduced hooks. This was the biggest shift in React's history.
useState replaced this.setState for functional components. useReducer gave Redux-like patterns without Redux.
useState
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
useReducer — for complex local state
const initialState = { count: 0 };
function reducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return initialState;
default: return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>{state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
Evaluation
Hooks were a massive improvement:
- no more class components
- cleaner, simpler code
- logic reuse via custom hooks
-
useReducerfor complex state without Redux
But hooks alone didn't fully solve global state or server state. Developers still needed something more.
5️⃣ Zustand / Jotai — Lightweight Global State (2019-2021)
After hooks, a new generation of state libraries emerged. Zustand became one of the most popular choices for lightweight global state — minimal API, no boilerplate, no providers, smart re-renders.
Example
import { create } from 'zustand';
// Create store — no Provider needed!
const useUserStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
clearUser: () => set({ user: null }),
}));
// Use in any component directly
function Avatar() {
const user = useUserStore(state => state.user);
return <img src={user.avatar} alt={user.name} />;
}
function LoginButton() {
const setUser = useUserStore(state => state.setUser);
return <button onClick={() => setUser(newUser)}>Login</button>;
}
Why Zustand Beats Context for Global State
// Context — re-renders ALL consumers when anything changes
const { user, theme, language } = useContext(AppContext);
// Zustand — only re-renders when YOUR specific slice changes
const user = useUserStore(state => state.user);
const theme = useThemeStore(state => state.theme);
Evaluation
Zustand solved:
- minimal API — easy to learn and use
- avoids boilerplate — no actions, reducers, or providers needed
- fine-grained re-renders — only re-renders what actually changed
Trade-offs
- less structure compared to Redux — no enforced patterns
- can become messy in very large apps without discipline
But neither Redux nor Zustand handled server state well. That was still a manual, painful process.
The Biggest Shift in React Thinking
The biggest evolution was not a library — it was a mindset shift.
For years, developers treated all state the same way. Everything went into Redux. Everything was managed manually. That was the real source of complexity.
The breakthrough came when the community finally named the distinction:
Client State vs Server State
Client State
State that lives on the client and is owned by the UI:
- UI interactions (open/close, active tab)
- form inputs
- toggles and local preferences
Server State
State that lives on the server and is only borrowed by the client:
- API data
- cached responses
- remote sync and background updates
Why This Matters
// ❌ Old thinking — everything in Redux
dispatch(fetchUser()) // server state in Redux
dispatch(toggleSidebar()) // client state in Redux
// ✅ New thinking — right tool for right state
const { data } = useQuery(...) // server state → React Query
const [open, setOpen] = useState() // client state → useState
Treating them the same caused years of unnecessary complexity. Separating them solved it.
6️⃣ React Query / SWR — Server State (2020)
React Query was built specifically for server state.
Example — Fetching Data
import { useQuery } from '@tanstack/react-query';
function UserProfile({ id }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', id],
queryFn: () => api.getUser(id),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage />;
return <div>{data.name}</div>;
}
Example — Updating Data
import { useMutation, useQueryClient } from '@tanstack/react-query';
function UpdateUser() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (user) => api.updateUser(user),
onSuccess: () => {
queryClient.invalidateQueries(['user']);
},
});
return (
<button onClick={() => mutation.mutate({ name: 'John' })}>
Update
</button>
);
}
Before vs After React Query
// ❌ Before React Query — manual everything
function UserProfile({ id }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
api.getUser(id)
.then(data => { setUser(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, [id]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage />;
return <div>{user.name}</div>;
}
// ✅ After React Query — clean and powerful
function UserProfile({ id }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', id],
queryFn: () => api.getUser(id),
});
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage />;
return <div>{data.name}</div>;
}
React Query handles most server-state concerns automatically
- loading and error states
- caching and cache invalidation
- background refetching
- deduplication of requests
- pagination and infinite scroll
- optimistic updates
- synchronization across components
The Modern Mental Model
After this entire evolution, here is the simple answer:
What kind of state is it?
↓
Server state (API data)?
→ React Query / SWR
Global UI state (auth, theme, cart)?
→ Zustand / Redux Toolkit / Context (depending on scale)
Complex local state?
→ useReducer
Simple local state?
→ useState
Simple shared state, small app?
→ Context API
Final Comparison
| Solution | Year | Best for | Problem |
|---|---|---|---|
| this.setState | 2013 | Local, isolated | Prop drilling |
| Redux | 2015 | Large app global state | Boilerplate hell |
| Context API | 2018 | Simple shared state | Re-render performance |
| useState / useReducer | 2019 | Local state, hooks era | Not for global/server state |
| Zustand | 2019 | Global UI state | Less structure in large apps |
| React Query | 2020 | Server / API state | Requires understanding cache & invalidation |
Most teams didn't have a state management problem.
They had a state categorization problem.
Conclusion
React state management did not evolve because the previous solutions were bad. Each one was the right answer for its time.
this.setState made sense in 2013 when React was brand new. Redux made sense in 2015 when Context was not yet a stable, practical solution and hooks didn't exist. Hooks made sense when class components became too verbose. Zustand and React Query made sense once developers realized UI state and server state are fundamentally different problems.
The key insight from this entire journey:
Not all state is the same. Stop managing server state like UI state.
Today the recommended approach is:
- React Query for anything that comes from an API
- Zustand for global UI state
- useState / useReducer for everything local
- Context API for simple shared config like theme or language
The complexity didn't disappear — we just got better at understanding what kind of problem we were actually solving.
What state management solution are you using in your current project? Are you still on Redux or have you moved to React Query + Zustand? Drop a comment below! 👇

Top comments (0)