Skip to content

When to Use Local State, Context, and External State Management in React

I was in an interview last week. The interviewer asked: “When should you use local state versus Context versus Redux?” I started rambling about performance and dev tools. The interviewer stopped me and said: “Give me a simple rule.”

I froze. I knew the answer but couldn’t organize my thoughts.

After the interview, I sat down and created a simple decision framework. Here it is.

The Problem

React gives you many ways to manage state. Too many, actually. You have useState, useReducer, Context API, Redux, Zustand, Jotai, Recoil… the list goes on.

When I started with React, I used Redux for everything. Even a simple toggle button got the Redux treatment. My code was over-engineered and hard to debug.

Then I swung the other way. I used only local state and passed props through five levels of components. Prop drilling madness.

The truth is somewhere in the middle. Let me show you the framework I use now.

The Three-Level Decision Framework

I think of state management as three levels. Each level handles a specific scope of data.

decision-levels.txt
Level 1: Local State -> One component needs it
Level 2: Context -> Multiple components in a subtree need it
Level 3: External Store -> Many parts of the app need it

Let me explain each level with code examples.

Level 1: Local State (useState)

Use useState when only ONE component cares about the data.

I use local state for:

  • Form inputs (text, checkboxes, selects)
  • Toggle switches (modal open/closed)
  • UI state (active tab, hovered item)
  • Temporary data before submit

Here’s a simple example:

SearchForm.jsx
function SearchForm({ onSearch }) {
const [query, setQuery] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
await onSearch(query);
setIsSubmitting(false);
};
return (
<form onSubmit={handleSubmit}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<button disabled={isSubmitting}>
{isSubmitting ? 'Searching...' : 'Search'}
</button>
</form>
);
}

The query and isSubmitting state only matter inside this component. No other component needs to know about them. This is the perfect use case for local state.

Common mistake: Putting form state in Redux. Don’t do it. Form state is usually temporary and local. Keep it local.

Level 2: Context API

Use Context when MULTIPLE components in a subtree need the same data.

I use Context for:

  • Theme (dark/light mode)
  • User preferences (language, font size)
  • Auth state (current user, login/logout)
  • Feature flags

Here’s the pattern I follow:

ThemeContext.jsx
const ThemeContext = createContext(undefined);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}

Now any component in the tree can access the theme:

Header.jsx
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={theme}>
<h1>My App</h1>
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
</header>
);
}
Sidebar.jsx
function Sidebar() {
const { theme } = useTheme();
return (
<nav className={theme}>
{/* sidebar content */}
</nav>
);
}

Both Header and Sidebar can access the theme without prop drilling. The provider wraps them at a higher level:

App.jsx
function App() {
return (
<ThemeProvider>
<Header />
<main>
<Sidebar />
<Content />
</main>
</ThemeProvider>
);
}

When NOT to use Context: If the state changes frequently (like mouse position or fast animations), Context might cause unnecessary re-renders. For those cases, consider splitting the context or using an external store.

Level 3: External State Management

Use Redux, Zustand, or similar libraries when:

  • Many distant components need the same state
  • State logic is complex (many action types, reducers)
  • You need time-travel debugging
  • State persists across page navigations

I reached for external state when building a shopping cart. The cart icon in the header needs to show the count. The product page needs to add items. The checkout page needs the full cart. These components are far apart in the tree.

Here’s how I’d do it with Zustand (simpler than Redux):

cartStore.js
import { create } from 'zustand';
const useCartStore = create((set, get) => ({
items: [],
addItem: (product) => {
set((state) => {
const existing = state.items.find(item => item.id === product.id);
if (existing) {
return {
items: state.items.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
};
}
return { items: [...state.items, { ...product, quantity: 1 }] };
});
},
removeItem: (productId) => {
set((state) => ({
items: state.items.filter(item => item.id !== productId)
}));
},
getTotal: () => {
return get().items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
},
getItemCount: () => {
return get().items.reduce((sum, item) => sum + item.quantity, 0);
}
}));
export default useCartStore;

Now any component can use the cart:

CartIcon.jsx
function CartIcon() {
const itemCount = useCartStore(state => state.getItemCount());
return (
<div className="cart-icon">
<span>Cart: {itemCount}</span>
</div>
);
}
AddToCartButton.jsx
function AddToCartButton({ product }) {
const addItem = useCartStore(state => state.addItem);
return (
<button onClick={() => addItem(product)}>
Add to Cart
</button>
);
}

The key benefit: components only re-render when the specific data they subscribe to changes. The CartIcon only subscribes to getItemCount. It won’t re-render when other cart details change.

Quick Decision Checklist

When I’m unsure, I ask myself these questions:

decision-checklist.txt
Q1: Does only one component need this state?
-> YES: Use useState
-> NO: Go to Q2
Q2: Do components in a specific subtree need this state?
-> YES: Use Context
-> NO: Go to Q3
Q3: Do many distant components need this state?
-> YES: Use external store (Zustand, Redux)

Common Interview Follow-up Questions

The interviewer might ask these follow-ups:

“What about useReducer?”

I use useReducer when local state logic gets complex. If I have multiple related state values or complex update logic, useReducer keeps things organized. It’s still local state, just structured differently.

“When would you choose Redux over Zustand?”

I’d pick Redux for large teams and enterprise apps. Redux has better dev tools, middleware ecosystem, and stricter patterns. Zustand is simpler and faster to set up. For smaller projects, Zustand wins.

“Can Context replace Redux entirely?”

Technically yes, but I wouldn’t recommend it. Context re-renders all consumers when the value changes. For frequently changing state, this hurts performance. External stores have better optimization built in.

The Mistake I Made

In my interview, I tried to explain everything at once. Performance, dev tools, bundle size, learning curve…

The interviewer wanted a simple mental model. A decision tree they could follow.

Start simple. Use useState by default. When props get annoying, reach for Context. When state gets complex and spans the whole app, bring in an external library.

That’s it. That’s the framework.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments