Welcome to Part 11 and the final article of the React Mastery Series!
As React applications grow, managing state becomes one of the most important challenges developers face.
At first, useState is usually enough. But what happens when multiple components need access to the same data? What if you have authentication, shopping carts, themes, notifications, filters, or complex application workflows?
That's where state management comes in.
In this article, we'll explore the most important state management approaches in React:
- React's built-in state management
- Lifting state up and prop drilling
- Context API
-
useReducerwith Context - Redux Toolkit
- Zustand
- Best practices and common mistakes
- How to choose the right solution
By the end, you'll understand when to use each approach and how to build maintainable state management for real-world applications.
1. Understanding State Management
State represents data that can change over time and affect what your application displays.
For example:
const [count, setCount] = useState(0);
When count changes, React updates the components that depend on it.
However, not all state belongs in the same place.
Local State
Local state is used by a single component or a small group of closely related components.
Examples:
- Input values
- Dropdown visibility
- Modal visibility
- Selected tabs
- Temporary form data
const [isOpen, setIsOpen] = useState(false);
Shared or Global Client State
Shared state is needed by multiple components across different parts of the application.
Examples:
- Current theme
- Shopping cart
- Authentication UI state
- User preferences
- Global notifications
Server State
Server state represents data that comes from an external source, such as an API.
Examples:
- User profiles
- Product lists
- Orders
- Comments
- Dashboard statistics
Server state has additional challenges:
- Caching
- Loading and error states
- Refetching
- Synchronization
- Pagination
- Stale data
Libraries such as TanStack Query and SWR are designed to handle these problems.
An important distinction:
Redux Toolkit, Zustand, and Context API can manage client-side application state. TanStack Query and SWR specialize in managing remote server data.
You don't necessarily need one tool to manage every kind of state.
2. React's Built-in State Management
Before installing an external library, understand the tools React already provides.
The most common options are:
useStateuseReduceruseContext
useState
Use useState for simple state.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h2>Count: {count}</h2>
<button onClick={() => setCount(count + 1)}>
Increment
</button>
</div>
);
}
export default Counter;
This is perfect when the state belongs to one component.
But what if several components need the same value?
That's where lifting state up becomes useful.
3. Lifting State Up and Prop Drilling
Imagine two components need access to the same username.
You can move the state to their closest common parent and pass it down through props.
function Parent() {
const [username, setUsername] = useState("Alex");
return (
<>
<Profile username={username} />
<Welcome username={username} />
</>
);
}
function Profile({ username }: { username: string }) {
return <h2>Profile: {username}</h2>;
}
function Welcome({ username }: { username: string }) {
return <h2>Welcome, {username}!</h2>;
}
This technique is called lifting state up.
It works well for small component trees.
What Is Prop Drilling?
Prop drilling happens when you pass data through multiple components that don't actually need it.
function App() {
const username = "Alex";
return <Layout username={username} />;
}
function Layout({ username }: { username: string }) {
return <Sidebar username={username} />;
}
function Sidebar({ username }: { username: string }) {
return <UserProfile username={username} />;
}
function UserProfile({ username }: { username: string }) {
return <h2>{username}</h2>;
}
As applications grow, this can become difficult to maintain.
One solution is Context API.
4. Context API
Context API is built into React and allows components to share values without passing props through every intermediate component.
It's useful for:
- Themes
- Authentication information
- Language preferences
- Application settings
- Other relatively stable shared values
Step 1: Create a Context
import { createContext } from "react";
type ThemeContextValue = {
theme: "light" | "dark";
toggleTheme: () => void;
};
export const ThemeContext =
createContext<ThemeContextValue | undefined>(undefined);
We use undefined as the default value so we can detect when a component is rendered outside its provider.
Step 2: Create a Provider
import {
createContext,
useCallback,
useMemo,
useState,
type ReactNode,
} from "react";
type ThemeContextValue = {
theme: "light" | "dark";
toggleTheme: () => void;
};
const ThemeContext =
createContext<ThemeContextValue | undefined>(undefined);
type ThemeProviderProps = {
children: ReactNode;
};
export function ThemeProvider({
children,
}: ThemeProviderProps) {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggleTheme = useCallback(() => {
setTheme((current) =>
current === "light" ? "dark" : "light"
);
}, []);
const value = useMemo(
() => ({ theme, toggleTheme }),
[theme, toggleTheme]
);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
The useCallback and useMemo calls help keep the provider value stable when its dependencies haven't changed.
They don't prevent consumers from updating when the actual context value changes.
Step 3: Create a Custom Hook
import { useContext } from "react";
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error(
"useTheme must be used within ThemeProvider"
);
}
return context;
}
This makes Context easier and safer to use.
Step 4: Use the Context
function ThemeButton() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
);
}
Step 5: Wrap Your Application
import { ThemeProvider } from "./ThemeContext";
function App() {
return (
<ThemeProvider>
<MainLayout />
</ThemeProvider>
);
}
Now any component inside ThemeProvider can access the theme.
Context API Limitations
Context is useful, but it isn't automatically a complete state management solution.
One important limitation is that consumers of a context can re-render when its provider value changes.
For larger applications, consider:
- Splitting unrelated contexts into separate providers.
- Keeping provider values stable when their dependencies haven't changed.
- Avoiding putting frequently changing, unrelated data into one large context.
- Using a state management library when you need more advanced subscriptions, selectors, or debugging tools.
Rule of thumb: Context is excellent for sharing values. It doesn't automatically provide all the features of a dedicated state management library.
5. useReducer with Context
When state becomes more complex, useReducer can provide a more structured approach than multiple useState calls.
A reducer receives the current state and an action, then returns the next state.
Example: Counter Reducer
import { useReducer } from "react";
type State = {
count: number;
};
type Action =
| { type: "increment" }
| { type: "decrement" }
| { type: "reset" };
const initialState: State = {
count: 0,
};
function counterReducer(
state: State,
action: Action
): State {
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(
counterReducer,
initialState
);
return (
<div>
<h2>{state.count}</h2>
<button onClick={() => dispatch({ type: "increment" })}>
+
</button>
<button onClick={() => dispatch({ type: "decrement" })}>
-
</button>
<button onClick={() => dispatch({ type: "reset" })}>
Reset
</button>
</div>
);
}
Instead of directly describing how to update state in every event handler, you dispatch actions describing what happened.
Combining useReducer with Context
You can expose both the state and dispatch function through Context.
import {
createContext,
useContext,
useReducer,
type ReactNode,
} from "react";
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;
}
}
const CounterStateContext = createContext<State | undefined>(
undefined
);
const CounterDispatchContext =
createContext<React.Dispatch<Action> | undefined>(undefined);
export function CounterProvider({
children,
}: {
children: ReactNode;
}) {
const [state, dispatch] = useReducer(reducer, {
count: 0,
});
return (
<CounterStateContext.Provider value={state}>
<CounterDispatchContext.Provider value={dispatch}>
{children}
</CounterDispatchContext.Provider>
</CounterStateContext.Provider>
);
}
export function useCounterState() {
const context = useContext(CounterStateContext);
if (!context) {
throw new Error("useCounterState requires CounterProvider");
}
return context;
}
export function useCounterDispatch() {
const context = useContext(CounterDispatchContext);
if (!context) {
throw new Error("useCounterDispatch requires CounterProvider");
}
return context;
}
Separating state and dispatch contexts can help components that only need to dispatch actions avoid subscribing to the changing state context.
However, components reading the state context still update when that context changes.
When should you use useReducer with Context?
- When multiple components share structured state.
- When updates involve several related actions.
- When you want predictable update logic.
- When you don't need the full ecosystem of an external library.
6. Redux Toolkit
Redux is a predictable state management library that centralizes application state in a store.
It is commonly used in large applications where predictable updates, debugging, middleware, and a mature ecosystem are important.
Today, Redux Toolkit (RTK) is the recommended approach for writing Redux logic.
It simplifies Redux and removes much of the repetitive code associated with older Redux patterns.
Install Redux Toolkit
npm install @reduxjs/toolkit react-redux
Redux Toolkit Core Concepts
| Concept | Purpose |
|---|---|
| Store | Holds the application state |
| Slice | Defines a feature's state and reducers |
| Action | Describes something that happened |
| Reducer | Calculates the next state |
| Dispatch | Sends an action to the store |
| Selector | Reads data from the store |
Step 1: Create a Slice
Imagine we're building a shopping cart.
// features/cart/cartSlice.ts
import {
createSlice,
type PayloadAction,
} from "@reduxjs/toolkit";
type CartItem = {
id: number;
name: string;
price: number;
quantity: number;
};
type CartState = {
items: CartItem[];
};
const initialState: CartState = {
items: [],
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addItem: (state, action: PayloadAction<CartItem>) => {
const existing = state.items.find(
(item) => item.id === action.payload.id
);
if (existing) {
existing.quantity += action.payload.quantity;
} else {
state.items.push(action.payload);
}
},
removeItem: (state, action: PayloadAction<number>) => {
state.items = state.items.filter(
(item) => item.id !== action.payload
);
},
clearCart: (state) => {
state.items = [];
},
},
});
export const {
addItem,
removeItem,
clearCart,
} = cartSlice.actions;
export default cartSlice.reducer;
Notice that we're using syntax that looks like direct mutation:
existing.quantity += action.payload.quantity;
Why is this safe?
Redux Toolkit uses Immer internally. Immer tracks these changes and produces the next immutable state.
This behavior is safe inside Redux Toolkit's Immer-managed reducers. It does not mean you should mutate Redux state directly elsewhere.
Step 2: Configure the Store
// app/store.ts
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "../features/cart/cartSlice";
export const store = configureStore({
reducer: {
cart: cartReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
The store combines your application's reducers and provides a central place for state.
Step 3: Provide the Store to React
// main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { store } from "./app/store";
import App from "./App";
ReactDOM.createRoot(
document.getElementById("root")!
).render(
<Provider store={store}>
<App />
</Provider>
);
Step 4: Create Typed Redux Hooks
When using TypeScript, typed hooks make Redux much easier to work with.
// app/hooks.ts
import {
useDispatch,
useSelector,
type TypedUseSelectorHook,
} from "react-redux";
import type {
RootState,
AppDispatch,
} from "./store";
export const useAppDispatch = () =>
useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> =
useSelector;
Now your components can access the store with proper TypeScript support.
Step 5: Use Redux in Components
import {
addItem,
removeItem,
} from "./features/cart/cartSlice";
import {
useAppDispatch,
useAppSelector,
} from "./app/hooks";
function Cart() {
const items = useAppSelector(
(state) => state.cart.items
);
const dispatch = useAppDispatch();
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return (
<div>
<h2>Shopping Cart</h2>
{items.map((item) => (
<div key={item.id}>
<span>
{item.name} x {item.quantity}
</span>
<button
onClick={() => dispatch(removeItem(item.id))}
>
Remove
</button>
</div>
))}
<h3>Total: ${total.toFixed(2)}</h3>
<button
onClick={() =>
dispatch(
addItem({
id: 1,
name: "Keyboard",
price: 80,
quantity: 1,
})
)
}
>
Add Keyboard
</button>
</div>
);
}
Handling Asynchronous Operations
Real applications often need to fetch data from APIs.
Redux Toolkit provides createAsyncThunk for managing asynchronous workflows.
import { createAsyncThunk } from "@reduxjs/toolkit";
type User = {
id: number;
name: string;
};
export const fetchUser = createAsyncThunk(
"users/fetchUser",
async (userId: number) => {
const response = await fetch(
`/api/users/${userId}`
);
if (!response.ok) {
throw new Error("Failed to fetch user");
}
return (await response.json()) as User;
}
);
You can handle the pending, fulfilled, and rejected states in the slice's extraReducers.
For applications with extensive API requirements, RTK Query is another excellent option. It is included in Redux Toolkit and provides API fetching, caching, and cache invalidation features.
When should you use Redux Toolkit?
- Large applications
- Complex shared state
- Many independent features
- Advanced debugging requirements
- Middleware and predictable action flows
- Teams that benefit from established conventions
7. Zustand
Zustand is a lightweight state management library for React.
It provides a simple API without requiring providers or a large amount of boilerplate.
It's especially useful when you want shared state with a straightforward developer experience.
Install Zustand
npm install zustand
Step 1: Create a Store
Let's create a simple counter store.
// stores/counterStore.ts
import { create } from "zustand";
type CounterState = {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
export const useCounterStore = create<CounterState>(
(set) => ({
count: 0,
increment: () =>
set((state) => ({
count: state.count + 1,
})),
decrement: () =>
set((state) => ({
count: state.count - 1,
})),
reset: () => set({ count: 0 }),
})
);
Step 2: Use the Store
import { useCounterStore } from "./stores/counterStore";
function Counter() {
const count = useCounterStore(
(state) => state.count
);
const increment = useCounterStore(
(state) => state.increment
);
const decrement = useCounterStore(
(state) => state.decrement
);
return (
<div>
<h2>{count}</h2>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
No provider is required.
Why Use Selectors?
Notice this pattern:
const count = useCounterStore(
(state) => state.count
);
A selector subscribes the component to the selected part of the store.
This can prevent unnecessary updates when unrelated parts of the store change.
Prefer selecting the specific values a component needs instead of subscribing to the entire store.
If you need to select multiple values as an object, consider Zustand's useShallow or an appropriate equality strategy to avoid unnecessary re-renders caused by newly created object references.
Zustand with TypeScript
The store above already uses a TypeScript interface to define its state and actions.
This makes it easier to maintain as the application grows.
You can also organize stores by domain:
src/
stores/
authStore.ts
cartStore.ts
themeStore.ts
Persisting State
Zustand provides a persist middleware for saving selected state across page reloads.
import { create } from "zustand";
import { persist } from "zustand/middleware";
type PreferencesState = {
theme: "light" | "dark";
setTheme: (theme: "light" | "dark") => void;
};
export const usePreferencesStore =
create<PreferencesState>()(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
}),
{
name: "user-preferences",
}
)
);
By default, this middleware uses browser storage where available.
Security note: Be careful about what you persist. Don't casually store sensitive information, authentication secrets, or access tokens in localStorage. Choose a storage strategy appropriate to your security requirements.
When Should You Use Zustand?
- Small and medium-sized applications
- Shared state with minimal boilerplate
- Projects that don't need Redux's full ecosystem
- Developers who prefer a simple store-based API
- Applications that benefit from selector-based subscriptions
8. Practical Example: Building a Shopping Cart with Zustand
Let's put everything together.
We'll build a shopping cart with the following features:
- Add products
- Remove products
- Update quantities
- Calculate the total price
Step 1: Define the Types
// types.ts
export type Product = {
id: number;
name: string;
price: number;
};
export type CartItem = {
product: Product;
quantity: number;
};
Step 2: Create the Cart Store
// stores/cartStore.ts
import { create } from "zustand";
import type { Product, CartItem } from "../types";
type CartState = {
items: CartItem[];
addItem: (product: Product) => void;
removeItem: (id: number) => void;
setQuantity: (id: number, quantity: number) => void;
clearCart: () => void;
};
export const useCartStore = create<CartState>(
(set) => ({
items: [],
addItem: (product) =>
set((state) => {
const existing = state.items.find(
(item) => item.product.id === product.id
);
if (existing) {
return {
items: state.items.map((item) =>
item.product.id === product.id
? {
...item,
quantity: item.quantity + 1,
}
: item
),
};
}
return {
items: [
...state.items,
{
product,
quantity: 1,
},
],
};
}),
removeItem: (id) =>
set((state) => ({
items: state.items.filter(
(item) => item.product.id !== id
),
})),
setQuantity: (id, quantity) =>
set((state) => ({
items:
quantity <= 0
? state.items.filter(
(item) => item.product.id !== id
)
: state.items.map((item) =>
item.product.id === id
? { ...item, quantity }
: item
),
})),
clearCart: () => set({ items: [] }),
})
);
Step 3: Build the Product List
import type { Product } from "./types";
import { useCartStore } from "./stores/cartStore";
const products: Product[] = [
{ id: 1, name: "Keyboard", price: 80 },
{ id: 2, name: "Mouse", price: 40 },
{ id: 3, name: "Monitor", price: 250 },
];
function ProductList() {
const addItem = useCartStore(
(state) => state.addItem
);
return (
<section>
<h2>Products</h2>
{products.map((product) => (
<div key={product.id}>
<span>
{product.name} — ${product.price}
</span>
<button onClick={() => addItem(product)}>
Add to Cart
</button>
</div>
))}
</section>
);
}
Step 4: Build the Cart Component
import { useCartStore } from "./stores/cartStore";
function ShoppingCart() {
const items = useCartStore(
(state) => state.items
);
const removeItem = useCartStore(
(state) => state.removeItem
);
const setQuantity = useCartStore(
(state) => state.setQuantity
);
const clearCart = useCartStore(
(state) => state.clearCart
);
const total = items.reduce(
(sum, item) =>
sum + item.product.price * item.quantity,
0
);
return (
<section>
<h2>Shopping Cart</h2>
{items.length === 0 ? (
<p>Your cart is empty.</p>
) : (
items.map((item) => (
<div key={item.product.id}>
<h3>{item.product.name}</h3>
<p>
${item.product.price} x {item.quantity}
</p>
<button
onClick={() =>
setQuantity(
item.product.id,
item.quantity - 1
)
}
>
-
</button>
<button
onClick={() =>
setQuantity(
item.product.id,
item.quantity + 1
)
}
>
+
</button>
<button
onClick={() => removeItem(item.product.id)}
>
Remove
</button>
</div>
))
)}
<h3>Total: ${total.toFixed(2)}</h3>
<button onClick={clearCart}>
Clear Cart
</button>
</section>
);
}
Step 5: Combine the Components
function App() {
return (
<main>
<h1>My Store</h1>
<ProductList />
<hr />
<ShoppingCart />
</main>
);
}
export default App;
That's it!
We've built a shared shopping cart without prop drilling or a provider component.
Notice that we calculate total from the cart items instead of storing it separately.
This is intentional.
If we stored both items and total, we would need to keep them synchronized after every change. Derived values are usually better calculated from their source data.
9. Context vs Redux Toolkit vs Zustand
How do these solutions compare?
| Feature | Context API | useReducer + Context | Redux Toolkit | Zustand |
|---|---|---|---|---|
| Built into React | Yes | Yes | No | No |
| External dependency | No | No | Yes | Yes |
| Boilerplate | Low | Medium | Medium | Low |
| Centralized state | Possible | Possible | Yes | Yes |
| Selector subscriptions | No built-in selector system | No built-in selector system | Yes | Yes |
| DevTools ecosystem | Limited | Limited | Excellent | Available |
| Async workflows | Manual | Manual | Middleware / RTK Query | Manual or integrations |
| Best for | Shared values | Structured shared state | Complex applications | Lightweight shared state |
Which One Should You Choose?
Context API
Choose Context when you need to share relatively stable values across a component tree.
useReducer + Context
Choose this when you need more structured update logic without installing another library.
Redux Toolkit
Choose this when your application has complex shared state, multiple feature domains, advanced debugging needs, or benefits from a mature ecosystem.
Zustand
Choose this when you want a lightweight global store with a simple API and selector-based subscriptions.
There is no universally best solution. The right choice depends on your application's complexity and requirements.
10. Server State vs Client State: Don't Mix Them Up
One common mistake is trying to manage API data in the same way as local UI state.
Imagine your application fetches a list of products.
You could store that list in Redux or Zustand, but then you may need to implement:
- Loading states
- Error handling
- Cache expiration
- Refetching
- Request deduplication
- Cache invalidation
These are server-state concerns.
A library such as TanStack Query handles many of them for you.
Example:
import { useQuery } from "@tanstack/react-query";
type Product = {
id: number;
name: string;
};
async function fetchProducts(): Promise<Product[]> {
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error("Failed to fetch products");
}
return response.json();
}
function Products() {
const {
data,
isPending,
isError,
} = useQuery({
queryKey: ["products"],
queryFn: fetchProducts,
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Something went wrong.</p>;
return (
<ul>
{data.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
A useful architecture might look like this:
React Application
│
├── Local UI State
│ └── useState
│
├── Complex Local State
│ └── useReducer
│
├── Shared Client State
│ ├── Context API
│ ├── Redux Toolkit
│ └── Zustand
│
└── Server State
├── TanStack Query
├── SWR
└── RTK Query
The goal isn't to use as many libraries as possible.
The goal is to use the right tool for each kind of state.
11. State Management Best Practices
Here are some principles that help keep React applications maintainable.
1. Keep Local State Local
Don't put every input value or modal visibility flag into a global store.
If only one component needs the state, keep it there.
2. Avoid Unnecessary Global State
Global state creates shared dependencies.
Only promote state when multiple parts of the application genuinely need it.
3. Separate Server State from Client State
Use tools designed for API caching and synchronization when working with remote data.
4. Avoid Duplicate Sources of Truth
Don't store the same information in multiple places unless you have a clear synchronization strategy.
For example, calculate cart totals from cart items instead of storing both independently.
5. Use Selectors
With Redux Toolkit and Zustand, subscribe to only the data your component needs.
This can reduce unnecessary rendering.
6. Organize State by Feature
For larger projects, keep state logic close to the feature that owns it.
src/
features/
auth/
authSlice.ts
AuthPage.tsx
cart/
cartSlice.ts
CartPage.tsx
products/
ProductsPage.tsx
app/
store.ts
hooks.ts
7. Persist Only What You Need
Not every piece of application state should survive a page refresh.
Consider what actually needs persistence and whether the chosen storage mechanism is appropriate.
8. Don't Over-Engineer
A simple useState solution is often better than introducing a global state library too early.
Choose complexity only when your application needs it.
12. Common State Management Mistakes
Let's review some mistakes worth avoiding.
Mistake 1: Making Everything Global
Not every component needs access to the same state.
Mistake 2: Using Context for High-Frequency Updates Without Considering Performance
Context consumers can re-render when the provider value changes. For frequently changing shared data, consider the update patterns and whether a selector-based store would be more appropriate.
Mistake 3: Mutating State Directly
React state and Zustand updates should follow immutable update patterns. Redux Toolkit's reducers are a special case because Immer manages immutable updates for you.
Mistake 4: Storing Derived State
Avoid storing values that can easily be calculated from existing state.
Mistake 5: Confusing Server State with Client State
API caching and synchronization are different problems from managing a modal or theme preference.
Mistake 6: Choosing a Library Before Understanding the Problem
Don't introduce Redux or Zustand simply because another project uses them.
Understand the requirements first.
13. How to Choose the Right State Management Solution
Here's a practical decision guide:
| Your Requirement | Recommended Solution |
|---|---|
| State used by one component | useState |
| Complex state transitions in one area | useReducer |
| Shared theme or application settings | Context API |
| Structured shared state without external dependencies | useReducer + Context |
| Large application with complex shared state | Redux Toolkit |
| Lightweight global state | Zustand |
| API data, caching, and synchronization | TanStack Query or SWR |
| API caching within a Redux application | RTK Query |
Remember:
Start simple. Add a state management library when your application's requirements justify it.
14. Practice Challenge: Build a State-Managed Application
Now it's your turn!
Build a small e-commerce application with these features:
Required Features
- A product list.
- Add products to a shopping cart.
- Remove products from the cart.
- Increase and decrease product quantities.
- Calculate the total price.
- Toggle between light and dark themes.
- Store theme preferences between page reloads.
Suggested Architecture
src/
components/
ProductList.tsx
ShoppingCart.tsx
ThemeToggle.tsx
stores/
cartStore.ts
themeStore.ts
types/
index.ts
App.tsx
Extra Challenges
- Add product search.
- Add category filters.
- Fetch products from an API using TanStack Query.
- Add authentication UI state.
- Add a checkout flow.
- Write tests for your store actions.
Try implementing the project with Zustand first.
Then rebuild the same application using Redux Toolkit.
Comparing both approaches is a great way to understand their differences.
15. Final Thoughts
State management is one of the most important skills for building scalable React applications.
Throughout this article, we explored:
- React's built-in state tools
- Lifting state up and prop drilling
- Context API
- useReducer with Context
- Redux Toolkit
- Zustand
- Server state management
- Practical state management patterns
- Best practices and common mistakes
The most important lesson is that there is no single state management solution for every React application.
Start with React's built-in tools. Understand your application's requirements. Introduce external libraries when they solve a real problem.
Good state management isn't about using the most powerful library.
It's about keeping your application predictable, maintainable, and easy to understand.
React Mastery Series — Complete Learning Path
Congratulations! You've reached the final article of the React Mastery Series.
Here is the complete list of all 11 parts:
- Part 1 — Introduction to React & Core Concepts
- Part 2 — JSX, Components & Props
- Part 3 — State & Event Handling in React
- Part 4 — React Hooks: useState, useEffect & Custom Hooks
- Part 5 — React Architecture & Best Practices
- Part 6 — React Performance Optimization
- Part 7 — Working with APIs & Data Fetching in React
- Part 8 — React Testing: Unit, Integration & End-to-End Testing
- Part 9 — React with TypeScript: Building Type-Safe Applications
- Part 10 — React Router: Routing, Nested Routes & Navigation
- Part 11 — State Management in React: Context API, Redux Toolkit & Zustand
This list represents the complete React Mastery Series learning path. Add links to the individual articles as they are published so readers can navigate through the entire series.
Thank You for Following the React Mastery Series!
If you've made it through all 11 parts, you've covered a substantial part of the React development journey.
Keep practicing, build real projects, read documentation, and experiment with different architectural approaches.
The best way to master React is to keep building.
Happy Coding! 🚀
Top comments (0)