DEV Community

Nagendra Namburi
Nagendra Namburi

Posted on

Why React State Management Changed - setState to Redux to React Query

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) ✅
Enter fullscreen mode Exit fullscreen mode

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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' });
  }
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Evaluation

Hooks were a massive improvement:

  • no more class components
  • cleaner, simpler code
  • logic reuse via custom hooks
  • useReducer for 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>;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)