Introduction
When I am in my College Third sem, I was debugging a E-commerce shopping cart wasn't updating when users clicked "Add to Cart." The button worked, the API call succeeded, but the cart count stayed at zero.
The problem? I had five different components trying to manage cart state independently. Sound familiar?
- Button component had its own loading state
- Cart icon tracked count separately
- Checkout page pulled from a different source
That night taught me something: state isn't just data storage—it's the single source of truth that keeps your entire app synchronized.
What Actually Is State?
State is any piece of data that can change and needs to trigger a UI update when it does.
After building 40+ React applications, I've learned to think of state in terms of who needs to know about changes:
- User's authentication status - everyone needs this
- Form input values - usually just the form
- API loading states - components showing that data
- Theme preferences - the entire app
Why State Management Matters (Learned the Hard Way)
I used to think state management was overkill for small apps. Then I maintained a codebase where:
- User data was fetched in 12 different places
- Props were passed down 6 levels deep
- Updating one piece of state required touching 8 files
Every bug fix created two new bugs. That's when I learned the hard way that good state management isn't about the tools—it's about having a clear mental model.
The Four Types of State (My Framework)
Over the years, I've found it helpful to categorize state by its scope and purpose:
| Type | Used For | My Go-To Solution |
|---|---|---|
| Component State | Toggle buttons, form inputs | useState |
| Shared State | User auth, theme, cart | Context or Zustand |
| Server State | API data, caching | React Query |
| URL State | Filters, pagination | URL params + hooks |
Component State: Start Simple
Most state should stay local to the component that uses it. Here's a pattern I use constantly:
const SearchInput = () => {
const [query, setQuery] = useState('');
const [isSearching, setIsSearching] = useState(false);
const handleSearch = async () => {
setIsSearching(true);
try {
await searchAPI(query);
} finally {
setIsSearching(false);
}
};
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={isSearching}
/>
<button onClick={handleSearch} disabled={isSearching}>
{isSearching ? 'Searching...' : 'Search'}
</button>
</div>
);
};
This handles input state and loading state locally. No prop drilling, no global state pollution. Keep it simple until you can't.
Shared State: When Components Need to Talk
The moment two components need the same data, you have a choice to make. I learned this from that shopping cart disaster.
Option 1: React Context (My Preference for Simple Cases)
const CartContext = createContext();
export const useCart = () => {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
};
const CartProvider = ({ children }) => {
const [items, setItems] = useState([]);
const addItem = (product) => {
setItems(prev => [...prev, { ...product, id: Date.now() }]);
};
const removeItem = (id) => {
setItems(prev => prev.filter(item => item.id !== id));
};
const total = items.reduce((sum, item) => sum + item.price, 0);
return (
<CartContext.Provider value={{ items, addItem, removeItem, total }}>
{children}
</CartContext.Provider>
);
};
Now any component can access cart state:
const CartButton = () => {
const { items, addItem } = useCart();
return <button onClick={() => addItem(product)}>{items.length}</button>;
};
Pro tip: I always create a custom hook like useCart() instead of exposing useContext directly. It gives me better error messages and makes refactoring easier.
When Context Isn't Enough: My State Library Picks
1. Zustand (My Current Favorite)
After years with Redux, I switched to Zustand for most projects. Less boilerplate, same power:
import { create } from 'zustand';
const useStore = create((set) => ({
user: null,
cart: [],
login: (userData) => set({ user: userData }),
addToCart: (item) => set((state) => ({
cart: [...state.cart, item]
})),
}));
// Usage in any component
const { user, addToCart } = useStore();
2. Redux Toolkit (For Large Teams)
I still reach for Redux when working with teams of 5+ developers. The structure helps prevent chaos:
// Modern Redux with RTK
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] },
reducers: {
addItem: (state, action) => {
state.items.push(action.payload);
}
}
});
Server State: Don't Reinvent the Wheel
This was a game-changer for me. Stop managing API data with useState. Use tools built for it:
import { useQuery, useMutation } from '@tanstack/react-query';
const UserProfile = () => {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
staleTime: 5 * 60 * 1000, // 5 minutes
});
const updateUser = useMutation({
mutationFn: updateUserAPI,
onSuccess: () => {
queryClient.invalidateQueries(['user']);
}
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user.name}</div>;
};
React Query handles caching, background updates, error states, and retries. I wish I'd discovered this sooner—it would have saved me from writing hundreds of lines of custom loading logic.
My Decision Framework
| Scenario | My Choice | Why |
|---|---|---|
| Form inputs, toggles | useState |
Local, simple, no sharing needed |
| User auth, theme | Context API | Global but simple data |
| Complex app state | Zustand | Less boilerplate than Redux |
| Large team project | Redux Toolkit | Structure prevents chaos |
| API data | React Query | Built for server state |
Hard-Won Lessons
- Start with local state - I used to jump to global state too quickly
- Separate concerns - UI state vs business logic vs server state
- Name your states clearly -
isLoadingis better thanloading - Handle error states - Users will notice when you don't
- Use TypeScript - Caught more state bugs than I care to admit
- Test state transitions - Not just the happy path
What I Wish I'd Known Starting Out
| Concept | Reality Check |
|---|---|
| State management | Not about tools—about mental models |
| Local state | Use useState until you can't |
| Global state | Context for simple, libraries for complex |
| Server state | Different beast—use React Query |
| Best practice | Whatever keeps your team productive |
Questions I Get Asked
1. "Should every app use Redux?"
No. I've built successful apps with just useState and React Query. Redux adds complexity—make sure you need it first. If you're asking this question, you probably don't need it yet.
2. "Context vs Zustand vs Redux?"
Context for simple global state (theme, auth). Zustand when Context gets messy. Redux when you need time-travel debugging or have complex async flows.
3. "How do I know when to lift state up?"
When the second component needs the same data. Not before. I made this mistake for years—over-engineering from day one.
4. "What about useReducer?"
Great for complex local state with multiple related values. I use it for form state that has validation rules. Think "mini-Redux for one component."
5. "Performance with Context?"
Context re-renders all consumers when value changes. Split contexts by concern, use useMemo for the value, or consider a state library if performance becomes an issue.