DEV Community

Cover image for React Mastery Series – Day 32: State Management Architecture – Redux Toolkit vs Context API vs TanStack Query
Siva Samanthapudi
Siva Samanthapudi

Posted on

React Mastery Series – Day 32: State Management Architecture – Redux Toolkit vs Context API vs TanStack Query

Welcome back to the React Mastery Series!

In Day 31, we explored Advanced React Architecture and learned how to design large-scale applications using:

Absolutely — let's continue with Day 32, focusing on one of the most important architectural decisions in a production React application.

  • Feature-based architecture
  • Clean Architecture principles
  • Domain boundaries
  • Separation of concerns
  • Dependency direction
  • Modular frontend design

Today, we're going to solve a question that comes up in almost every React architecture discussion:

Where should application state live?

Should we use:

  • useState?
  • Context API?
  • Redux Toolkit?
  • TanStack Query?
  • Something else?

The answer is:

It depends on what kind of state you're managing.

Understanding this distinction is much more important than simply knowing how to use a state-management library.


What Is State?

State is information that can change over time and affect what the application displays or does.

Examples:

User logged in
Selected account
Shopping cart
Modal opened
Theme selected
API response
Loading status
Search text
Enter fullscreen mode Exit fullscreen mode

But not all state is the same.

That's the key architectural insight.


Two Major Categories of State

A useful way to think about application state is:

Application State
       │
       ├── Client State
       └── Server State
Enter fullscreen mode Exit fullscreen mode

Let's understand the difference.


Client State

Client state is primarily owned and controlled by the frontend.

Examples:

  • Modal visibility
  • Selected tab
  • Form input
  • Theme
  • Sidebar state
  • Local UI preferences

Example:

const [isOpen, setIsOpen] = useState(false);
Enter fullscreen mode Exit fullscreen mode

The server doesn't care whether your modal is open.

Therefore, this is client state.


Server State

Server state comes from a backend system.

Examples:

  • User profile
  • Account balances
  • Transactions
  • Products
  • Orders
  • Notifications

For example:

React Application
       │
       ▼
GET /accounts
       │
       ▼
Backend
       │
       ▼
Account Data
Enter fullscreen mode Exit fullscreen mode

The backend owns this data.

The frontend is essentially consuming and caching it.

This is server state.


Why This Distinction Matters

A common mistake is putting everything into Redux.

For example:

API Response
      ↓
Redux
      ↓
Component
Enter fullscreen mode Exit fullscreen mode

This can work.

But server state has unique requirements:

  • Caching
  • Refetching
  • Deduplication
  • Background synchronization
  • Retry handling
  • Pagination
  • Stale data management

Redux itself doesn't automatically solve all of these concerns.

That's where tools such as TanStack Query become useful.


State Management Decision Tree

A practical decision framework:

Do I need state?
      │
      ▼
Only one component?
      │
     Yes
      │
      ▼
   useState
Enter fullscreen mode Exit fullscreen mode

If multiple components need it:

      │
      ▼
Is it simple shared state?
      │
     Yes
      │
      ▼
 Context API
Enter fullscreen mode Exit fullscreen mode

If it's complex global client state:

      │
      ▼
 Redux Toolkit
Enter fullscreen mode Exit fullscreen mode

If the data comes from a server:

      │
      ▼
 TanStack Query
Enter fullscreen mode Exit fullscreen mode

This isn't an absolute rule, but it's a useful starting point.


1. useState

Start with the simplest solution.

Example:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      {count}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

If only one component needs the state, don't introduce a global state library.


2. useReducer

When local state becomes more complex, useReducer can help.

Example:

type State = {
  count: number;
};

type Action =
  | { type: "increment" }
  | { type: "decrement" };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "increment":
      return {
        count: state.count + 1,
      };

    case "decrement":
      return {
        count: state.count - 1,
      };

    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

This is useful when state transitions become more structured.


3. Context API

Context allows values to be shared across a component tree without passing props through every level.

Example:

const ThemeContext = createContext("light");
Enter fullscreen mode Exit fullscreen mode

Provider:

<ThemeContext.Provider value="dark">
  <App />
</ThemeContext.Provider>
Enter fullscreen mode Exit fullscreen mode

Consumer:

const theme = useContext(ThemeContext);
Enter fullscreen mode Exit fullscreen mode

Good Context API Use Cases

Context works well for relatively stable cross-cutting concerns such as:

Theme
Authentication Context
Localization
Feature Configuration
Enter fullscreen mode Exit fullscreen mode

Example:

<AuthProvider>
  <App />
</AuthProvider>
Enter fullscreen mode Exit fullscreen mode

When Context Can Become a Problem

Consider:

Global Context
      │
      ├── User
      ├── Notifications
      ├── Accounts
      ├── Transactions
      ├── Preferences
      └── Payments
Enter fullscreen mode Exit fullscreen mode

Now many unrelated values live inside the same context.

A change to one value can cause consumers to re-render unnecessarily.

The solution isn't necessarily "never use Context."

Instead:

Keep contexts focused and avoid turning Context into a global dumping ground.


4. Redux Toolkit

Redux Toolkit is a powerful choice for complex client-side state.

Example:

features
├── auth
│   └── authSlice.ts
├── cart
│   └── cartSlice.ts
└── preferences
    └── preferencesSlice.ts
Enter fullscreen mode Exit fullscreen mode

A slice:

import { createSlice, PayloadAction } from "@reduxjs/toolkit";

type CartState = {
  items: string[];
};

const initialState: CartState = {
  items: [],
};

const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {
    addItem(
      state,
      action: PayloadAction<string>
    ) {
      state.items.push(action.payload);
    },

    removeItem(
      state,
      action: PayloadAction<string>
    ) {
      state.items =
        state.items.filter(
          (item) => item !== action.payload
        );
    },
  },
});

export const {
  addItem,
  removeItem,
} = cartSlice.actions;

export default cartSlice.reducer;
Enter fullscreen mode Exit fullscreen mode

Redux Toolkit simplifies traditional Redux significantly.


When Redux Makes Sense

Redux Toolkit can be valuable when you have:

  • Complex shared state
  • Multiple state transitions
  • Cross-feature workflows
  • Centralized client-side state
  • Strong debugging requirements
  • Middleware requirements
  • Large development teams

For example:

Payment Workflow
↓
Selected Account
↓
Selected Beneficiary
↓
Payment Amount
↓
Validation
↓
Confirmation
↓
Submission Status
Enter fullscreen mode Exit fullscreen mode

If many unrelated components participate in this workflow, centralized state can be useful.


5. TanStack Query

Now we come to an important distinction.

Suppose your application needs:

GET /accounts
Enter fullscreen mode Exit fullscreen mode

The response needs to be:

  • Cached
  • Refetched
  • Shared between components
  • Invalidated after mutations
  • Retried when appropriate

This is a server-state problem.

TanStack Query is designed specifically for this kind of workflow.

Example:

import { useQuery } from "@tanstack/react-query";

function Accounts() {
  const { data, isPending, isError } = useQuery({
    queryKey: ["accounts"],
    queryFn: getAccounts,
  });

  if (isPending) {
    return <p>Loading...</p>;
  }

  if (isError) {
    return <p>Unable to load accounts.</p>;
  }

  return (
    <ul>
      {data.map((account) => (
        <li key={account.id}>
          {account.name}
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

The library manages much of the server-state lifecycle for you.


Server State Lifecycle

A server-state library can manage concepts such as:

Request
   ↓
Loading
   ↓
Success
   ↓
Cache
   ↓
Stale
   ↓
Refetch
   ↓
Updated Cache
Enter fullscreen mode Exit fullscreen mode

This is considerably more than simply storing an API response in Redux.


Mutations

Reading data is only half the story.

Applications also modify server data.

Example:

const mutation = useMutation({ mutationFn: createPayment });
Enter fullscreen mode Exit fullscreen mode

Then:

await mutation.mutateAsync(payment);
Enter fullscreen mode Exit fullscreen mode

After a successful mutation, related queries can be invalidated so the UI gets fresh server data.


Redux vs TanStack Query

A useful mental model:

Requirement Redux Toolkit TanStack Query
Client state
Server state Possible
Caching API data Manual Built-in
Refetching Manual Built-in
Global workflows Limited
Complex state transitions
Background synchronization Manual

The libraries solve different problems.


Can We Use Both?

Absolutely.

A production application can use:

React
 │
 ├── useState
 ├── Context
 ├── Redux Toolkit
 └── TanStack Query
Enter fullscreen mode Exit fullscreen mode

For example:

Authentication
      ↓
Redux Toolkit

Theme
      ↓
Context API

Transaction API Data
      ↓
TanStack Query

Modal State
      ↓
useState
Enter fullscreen mode Exit fullscreen mode

This is often more maintainable than forcing everything into one solution.


Example Banking Application

Imagine a digital banking application.

Local UI State

Selected tab
Modal visibility
Form input
Enter fullscreen mode Exit fullscreen mode

Use:

useState
Enter fullscreen mode Exit fullscreen mode

Authentication

Current user
Authentication status
Session information
Enter fullscreen mode Exit fullscreen mode

Could use:

Context
Enter fullscreen mode Exit fullscreen mode

or a centralized client-state solution depending on the application's requirements.


Account Data

Account balances
Transactions
Statements
Enter fullscreen mode Exit fullscreen mode

Use:

TanStack Query
Enter fullscreen mode Exit fullscreen mode

because this is server-owned data.


Complex Payment Workflow

Account
Beneficiary
Amount
Validation
Confirmation
Submission
Enter fullscreen mode Exit fullscreen mode

Could use:

Redux Toolkit
Enter fullscreen mode Exit fullscreen mode

if the workflow requires complex cross-component client state.


Avoid Duplicate Sources of Truth

One of the biggest architecture problems is storing the same data in multiple places.

For example:

Backend
   ↓
TanStack Query
   ↓
Redux
   ↓
Component State
Enter fullscreen mode Exit fullscreen mode

Now you have multiple copies of the same information.

Which one is correct?

This creates synchronization problems.

Prefer a single source of truth for each category of state.


State Ownership

A powerful question to ask is:

Who owns this state?

For example:

Modal Open?
→ Component

Theme?
→ Context

Account Balance?
→ Server

Payment Draft?
→ Application State

Shopping Cart?
→ Global Client State
Enter fullscreen mode Exit fullscreen mode

Once ownership is clear, choosing the state-management solution becomes much easier.


State Colocation

Another important principle is:

Keep state as close as possible to where it is used.

Bad:

Entire Application
        ↓
Global State
        ↓
Modal Visibility
Enter fullscreen mode Exit fullscreen mode

Better:

function PaymentPage() {
  const [isModalOpen, setIsModalOpen] =
    useState(false);

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Don't make local state global without a reason.


State Normalization

Large applications may have deeply nested data.

Example:

{
  "users": [
    {
      "id": 1,
      "accounts": [
        {
          "id": 101
        }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Normalized state can reduce duplication.

Conceptually:

users
├── 1
└── 2

accounts
├── 101
└── 102
Enter fullscreen mode Exit fullscreen mode

Normalization becomes especially useful when entities are shared across many parts of the application.


Selectors

With Redux, selectors allow components to read only the state they need.

Example:

const selectCartItems = (
  state: RootState
) => state.cart.items;
Enter fullscreen mode Exit fullscreen mode

Component:

const items = useSelector(selectCartItems);
Enter fullscreen mode Exit fullscreen mode

Selectors can help encapsulate state access and reduce unnecessary coupling to the store structure.


Performance Considerations

State architecture also affects rendering performance.

Imagine:

Global State Changes

↓

100 Components Subscribe

↓

Many Components Re-render
Enter fullscreen mode Exit fullscreen mode

Instead, design state boundaries carefully.

Use:

  • Focused contexts
  • Redux selectors
  • Local state
  • Memoization when justified
  • Server-state caching

Performance should be measured rather than optimized blindly.


A Practical Decision Matrix

When deciding where state belongs:

Question Preferred Option
Only one component needs it? useState
Complex local transitions? useReducer
Shared stable configuration? Context
Complex global client state? Redux Toolkit
Backend/server-owned data? TanStack Query
Temporary UI state? Local state

This isn't a strict law.

It's a starting point for architectural decisions.


Recommended Enterprise Architecture

A mature React application might look like:

                    React App
                       │
       ┌───────────────┼────────────────┐
       │               │                │
   Local State       Context          Redux
       │               │                │
   useState       Theme/Auth       Client State
       │
       │
       └───────────────┐
                       │
                TanStack Query
                       │
                       ▼
                 Backend APIs
Enter fullscreen mode Exit fullscreen mode

Each tool has a clearly defined responsibility.


Common Mistakes

1. "Everything Goes Into Redux"

This often creates unnecessary complexity.


2. Using Context for Everything

Context is not automatically a replacement for Redux or a server-state library.


3. Putting API Data Into Multiple Stores

Avoid maintaining duplicate copies of server data unless there is a strong architectural reason.


4. Making Local State Global

A state value being shared by two components doesn't automatically mean it belongs in a global store.


5. Choosing a Library Before Understanding the Problem

Don't start with:

"Should we use Redux?"

Start with:

"What kind of state are we managing?"

That's the architectural question.


Senior Frontend Engineer Mindset

A junior developer asks:

"Which state library should I use?"

A senior developer asks:

"What type of state is this?"

An architect asks:

"Who owns this state, who consumes it, how long does it live, and what consistency guarantees do we need?"

That difference in thinking is what makes state architecture scalable.


Key Takeaways

Today, we learned:

useState is ideal for local component state.
useReducer helps manage complex local state transitions.
✅ Context works well for focused cross-cutting concerns.
✅ Redux Toolkit is useful for complex global client state.
✅ TanStack Query is designed for server-state management.
✅ Avoid multiple sources of truth.
✅ Keep state as close as possible to where it is used.
✅ Choose the state-management solution based on the problem—not popularity.


Coming Next 🚀

In Day 33, we'll explore another critical area of frontend architecture:

React API Architecture – Axios, Fetch, Service Layers, Interceptors & Error Handling

We'll build a production-ready API architecture covering:

  • API client configuration
  • Axios vs Fetch
  • Service layers
  • Request interceptors
  • Response interceptors
  • Authentication headers
  • Token refresh
  • Centralized error handling
  • Request cancellation
  • Retry strategies
  • API types with TypeScript
  • Handling multiple backend services

This will bring together many concepts we've already learned and show how they fit into a real enterprise React application.

Happy Coding! 🚀

Top comments (0)