React State Management in Practice: Choosing the Right Pattern for Real UI Problems

TL;DR – Before you reach for Redux, Zustand, or any other “store” library, ask yourself three questions:
Where should the state live?
Who owns the state, and how does it change?
How do we guarantee that the UI is always a pure function of that state?
If the answers are clear, you’ll rarely need a heavyweight solution. If they’re fuzzy, you’ll spend weeks chasing bugs that a better pattern would have prevented.
1. Why State Management Fails in Real Applications
Small demo components look perfect. As soon as a UI grows into multiple interdependent pieces, things start breaking.
Below are the most common failure modes:
| Failure Mode | What It Looks Like | Why It Hurts |
|---|---|---|
| Over-centralizing | A global store holds everything from modals to form inputs | Triggers excessive re-renders and tight coupling |
| Fragmented ownership | Different components track overlapping pieces of state | Leads to duplicated sources of truth |
| Stored derived state | useState(computeTotal(items)) |
Easily becomes stale when source changes |
| Unpredictable transitions | Multiple state updates relying on current values | Leads to stale closures and incorrect UI |
| UI ↔ State mismatch | UI updates but internal state doesn’t | Creates inconsistent, hard-to-debug behavior |
Example: Unpredictable Transitions
setCount(count + 1);
setTotal(total + count);
This introduces:
stale values (closure issue)
non-deterministic ordering
Key Insight
State problems are not React problems they are ownership and modeling problems.
Fragmented State Ownership
The opposite problem is spreading related state across multiple components.
Example:
A parent holds a list of tasks
A child component tracks “completed tasks” separately
Now you have:
duplicated sources of truth
synchronization bugs
Failure case:
Marking a task complete updates one state but not the other → UI inconsistency.
Derived State Stored Instead of Computed
Derived state is one of the most subtle sources of bugs.
Example:
const [tasks, setTasks] = useState([]);
const [completedTasks, setCompletedTasks] = useState([]);
If completedTasks is derived from tasks, storing both creates risk.
Failure case:
A task is updated in
taskscompletedTasksis not recomputedUI shows stale data
Correct approach:
const completedTasks = tasks.filter(task => task.completed);
Derived state should be computed, not stored, unless there is a strong performance reason.
2. Patterns That Scale (Without New Libraries)
These patterns solve most real-world problems without introducing external state managers.
2.1 Local-First, Lift-When-Necessary
Rule: Start local, lift only when needed.
function TodoItem({ task, onToggle }) {
const [editing, setEditing] = useState(false);
}
When multiple components need the same state:
export function useTodoState(initial = []) {
const [tasks, setTasks] = useState(initial);
const toggle = (id) =>
setTasks(t =>
t.map(task =>
task.id === id
? { ...task, completed: !task.completed }
: task
)
);
return { tasks, toggle };
}
Why this works
Minimal re-renders
Single source of truth
Clear ownership
2.2 Compound Components + Context
Best for multi-part UIs (tabs, modals, accordions).
const TabsContext = createContext(null);
export function Tabs({ children, defaultIndex = 0 }) {
const [selected, setSelected] = useState(defaultIndex);
return (
<TabsContext.Provider value={{ selected, setSelected }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
export function Tab({ index, children }) {
const { selected, setSelected } = useContext(TabsContext);
const isActive = selected === index;
return (
<button onClick={() => setSelected(index)}>
{children}
</button>
);
}
Benefits
No prop drilling
Explicit ownership
Composable architecture
2.3 Selective Context (Avoid Global Re-Renders)
Split state and actions:
const AuthStateContext = createContext(null);
const AuthDispatchContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const state = useMemo(() => ({ user }), [user]);
const actions = useMemo(() => ({
login: (cred) => setUser(cred),
logout: () => setUser(null)
}), []);
return (
<AuthStateContext.Provider value={state}>
<AuthDispatchContext.Provider value={actions}>
{children}
</AuthDispatchContext.Provider>
</AuthStateContext.Provider>
);
}
Key Idea
Only subscribe to what you need.
2.4 useReducer for Predictable Transitions
Use when updates become complex.
function reducer(state, action) {
switch (action.type) {
case 'inc':
return { ...state, count: state.count + 1 };
case 'addToTotal':
return { ...state, total: state.total + action.payload };
default:
return state;
}
}
const [state, dispatch] = useReducer(reducer, {
count: 0,
total: 0
});
Why it matters
Centralized logic
Predictable updates
Easier debugging
2.5 State Machines (Advanced UIs)
For strict UI flows:
const modalMachine = createMachine({
initial: 'closed',
states: {
closed: { on: { OPEN: 'open' } },
open: { on: { CLOSE: 'closed' } }
}
});
Benefits
Impossible states are eliminated
Transitions are explicit
2.6 Domain-Specific Custom Hooks
Encapsulate logic cleanly:
export function useCart(initialItems = []) {
const [items, setItems] = useState(initialItems);
const add = (product) => setItems(i => [...i, product]);
const remove = (id) => setItems(i => i.filter(p => p.id !== id));
const total = useMemo(
() => items.reduce((sum, p) => sum + p.price, 0),
[items]
);
return { items, add, remove, total };
}
Result
Cleaner UI components
Reusable logic
Better separation of concerns
3. Real-World Walkthrough: Kanban Board
Requirements
Multiple columns
Drag & drop
Editable cards
Filtering
3.1 Single Source of Truth
export function useBoard(initialCards) {
const [cards, setCards] = useState(initialCards);
const moveCard = (id, status) =>
setCards(c =>
c.map(card =>
card.id === id ? { ...card, status } : card
)
);
const editCard = (id, updates) =>
setCards(c =>
c.map(card =>
card.id === id ? { ...card, ...updates } : card
)
);
return { cards, moveCard, editCard };
}
3.2 Derived State (Not Stored)
const visibleCards = useMemo(() => {
if (filter === 'all') return cards;
return cards.filter(card => card.owner === filter);
}, [cards, filter]);
Never store what can be derived.
3.3 Local vs Shared State
function CardItem({ card }) {
const [editing, setEditing] = useState(false);
}
UI state → local
Data state → shared
3.4 Coordinated Updates
const save = () => {
editCard(card.id, draft);
setEditing(false);
};
Single update path
Predictable behavior
Final Takeaway
Good state management is not about tools but about structure.
Store the minimum possible state
Derive everything else
Make transitions explicit
When this is done correctly:
UI becomes predictable
Bugs become easier to trace
Complexity stays controlled
And most importantly:
The UI becomes a reliable function of state.



