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
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
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);
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
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
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
If multiple components need it:
│
▼
Is it simple shared state?
│
Yes
│
▼
Context API
If it's complex global client state:
│
▼
Redux Toolkit
If the data comes from a server:
│
▼
TanStack Query
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>
);
}
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;
}
}
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");
Provider:
<ThemeContext.Provider value="dark">
<App />
</ThemeContext.Provider>
Consumer:
const theme = useContext(ThemeContext);
Good Context API Use Cases
Context works well for relatively stable cross-cutting concerns such as:
Theme
Authentication Context
Localization
Feature Configuration
Example:
<AuthProvider>
<App />
</AuthProvider>
When Context Can Become a Problem
Consider:
Global Context
│
├── User
├── Notifications
├── Accounts
├── Transactions
├── Preferences
└── Payments
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
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;
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
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
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>
);
}
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
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 });
Then:
await mutation.mutateAsync(payment);
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
For example:
Authentication
↓
Redux Toolkit
Theme
↓
Context API
Transaction API Data
↓
TanStack Query
Modal State
↓
useState
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
Use:
useState
Authentication
Current user
Authentication status
Session information
Could use:
Context
or a centralized client-state solution depending on the application's requirements.
Account Data
Account balances
Transactions
Statements
Use:
TanStack Query
because this is server-owned data.
Complex Payment Workflow
Account
Beneficiary
Amount
Validation
Confirmation
Submission
Could use:
Redux Toolkit
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
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
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
Better:
function PaymentPage() {
const [isModalOpen, setIsModalOpen] =
useState(false);
// ...
}
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
}
]
}
]
}
Normalized state can reduce duplication.
Conceptually:
users
├── 1
└── 2
accounts
├── 101
└── 102
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;
Component:
const items = useSelector(selectCartItems);
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
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
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)