Skip to main content

Command Palette

Search for a command to run...

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

Updated
6 min read
React State Management in Practice: Choosing the Right Pattern for Real UI Problems
A
A technical extrovert building smart contract dapps. Specialized in communicating technical blockchain concepts to the web2 maxis.

TL;DR – Before you reach for Redux, Zustand, or any other “store” library, ask yourself three questions:

  1. Where should the state live?

  2. Who owns the state, and how does it change?

  3. 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 tasks

  • completedTasks is not recomputed

  • UI 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.