Siddhant Deval
Siddhant Deval
frontend5 min read

The Hook Soup Anti-Pattern: Why Complex React Apps Collapse Without OOP

React's functional paradigm is brilliant for mapping state to DOM nodes, but treating components as business logic containers creates 'Hook Soup' — a tangle of coordinated useState hooks that produces stale closures, cascading re-renders, and untestable architectures. This article dissects the failure mode and introduces the architectural separation that fixes it.

Series·Part 1 of 12

Frontend Object-Oriented Architecture

The Hook Soup Anti-Pattern: Why Complex React Apps Collapse Without OOP

There is a specific inflection point in the lifecycle of every complex React application built purely with hooks. It happens at somewhere between six and twelve months of development. The symptoms are consistent across projects: state updates produce unexpected side effects in unrelated components, fixing one bug introduces two others in distant parts of the codebase, useEffect dependency arrays grow to eight or ten items and nobody is sure what will break if one is removed, and every new feature requires reading and understanding three hundred lines of interleaved state logic before writing the first line of new code.

This article names the specific anti-patterns that accumulate to produce this collapse, traces each one to its root cause, and establishes the structural diagnosis that motivates the OOP approach built across the rest of this series. The domain: a vector drawing tool's state management — complex enough to exhibit every failure mode.


1. The Hook Soup Definition

Hook Soup is the state that emerges when complex, interconnected business logic is expressed entirely in React hooks — useState, useCallback, useEffect, useRef, useMemo — without any structural separation between UI concerns and domain concerns. The name comes from the resulting code: a liquid mixture of concerns where individual components are no longer separable.

Hook Soup has six identifiable failure modes. They compound.


2. Failure Mode 1: Entangled State

State entanglement occurs when multiple useState variables have invariants between them that React cannot enforce:

TYPESCRIPT
// ❌ Entangled state — these three variables must always be consistent
const [shapes, setShapes] = useState<ShapeData[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectionBounds, setSelectionBounds] = useState<Rect | null>(null);

// The invariant: selectionBounds must always reflect the bounding box of the selectedId shape.
// React cannot enforce this. Every caller must remember to update all three.

const selectShape = useCallback((id: string) => {
  setSelectedId(id);
  const shape = shapes.find(s => s.id === id);
  if (shape) setSelectionBounds(computeBounds(shape)); // ← Easy to forget
  // If a caller forgets setSelectionBounds, the UI shows stale selection bounds
}, [shapes]);

const moveShape = useCallback((id: string, dx: number, dy: number) => {
  setShapes(prev => prev.map(s => s.id === id ? { ...s, x: s.x + dx, y: s.y + dy } : s));
  // ← Did you remember to update selectionBounds? The shape moved — bounds are stale now.
  // There is no mechanism to enforce this. It is a convention, not a guarantee.
}, []);

After six mutations functions exist — moveShape, resizeShape, rotateShape, flipShape, groupShapes, alignShapes — the invariant "selection bounds must match selected shape position" has been violated at least twice by developers who did not notice it needed updating. The selection handles now render at the wrong position for rotated shapes.

The domain object solution:

TYPESCRIPT
// ✅ Invariant enforced structurally — selection bounds are computed, not stored
class CanvasDocument {
  #shapes: Map<ShapeId, Shape> = new Map();
  #selectedId: ShapeId | null = null;

  // selectionBounds is a derived computation — it CANNOT be stale
  get selectionBounds(): Rect | null {
    if (!this.#selectedId) return null;
    const shape = this.#shapes.get(this.#selectedId);
    return shape ? shape.getBounds() : null;
  }

  moveShape(id: ShapeId, dx: number, dy: number): void {
    const shape = this.#shapes.get(id);
    if (!shape) throw new DomainError(`Shape ${id} not found`);
    shape.move(dx, dy);
    // selectionBounds automatically reflects the new position — no synchronization needed
    this.emit('changed');
  }
}

The invariant cannot be violated because selectionBounds is computed from the source of truth (#shapes) every time it is read. There is no secondary copy to become stale.


3. Failure Mode 2: The useEffect Cascade

Effect cascades occur when one useEffect triggers a state update that triggers another useEffect that triggers another state update:

TYPESCRIPT
// ❌ The useEffect cascade — three effects that chain on each other
useEffect(() => {
  // Effect 1: When selectedId changes, compute bounds
  if (selectedId) {
    const shape = shapes.find(s => s.id === selectedId);
    if (shape) setSelectionBounds(computeBounds(shape));
  } else {
    setSelectionBounds(null);
  }
}, [selectedId, shapes]); // ← Both selectedId AND shapes are dependencies

useEffect(() => {
  // Effect 2: When selectionBounds changes, update toolbar state
  if (selectionBounds) {
    setToolbarPosition({ x: selectionBounds.x, y: selectionBounds.y - 40 });
  }
}, [selectionBounds]);

useEffect(() => {
  // Effect 3: When toolbarPosition changes, check if it's off-screen and adjust
  if (toolbarPosition) {
    const adjusted = clampToViewport(toolbarPosition, viewportBounds);
    if (!isEqual(adjusted, toolbarPosition)) {
      setToolbarPosition(adjusted); // ← Triggers Effect 2 again if bounds change
    }
  }
}, [toolbarPosition, viewportBounds]);

This is a three-step cascade: selectedId change → bounds computed → toolbar positioned → viewport clamp → toolbar repositioned. Each step is a render. Three extra renders per selection change. In React DevTools Profiler, this appears as a "waterfall" of renders that are difficult to attribute to the original state change.

Adding a fourth dependency (viewportBounds changes on zoom) can introduce a cycle that React's strict mode will catch in development — but not always in production.

The domain object solution: derived state is computed synchronously inside the domain object. No effects needed:

TYPESCRIPT
// ✅ No effects — all derived state is computed synchronously in domain methods
class CanvasDocument {
  // The toolbar position is computed during a single getSnapshot() call
  getSnapshot(): CanvasSnapshot {
    const selectedShape = this.#selectedId ? this.#shapes.get(this.#selectedId) : null;
    const selectionBounds = selectedShape?.getBounds() ?? null;
    const toolbarPosition = selectionBounds
      ? clampToViewport({ x: selectionBounds.x, y: selectionBounds.y - 40 }, this.#viewport)
      : null;

    return {
      shapes: Array.from(this.#shapes.values()).map(s => s.toData()),
      selectedId: this.#selectedId,
      selectionBounds,
      toolbarPosition, // Computed once, consistent, no cascade
    };
  }
}

One synchronous computation. One React render. Zero effect cascade.


4. Failure Mode 3: Callback Hell and Stale Closures

The stale closure problem occurs when a useCallback captures state at creation time, and that state changes before the callback is invoked:

TYPESCRIPT
// ❌ Stale closure — deleteShape captures 'shapes' at render N
const deleteShape = useCallback((id: string) => {
  // 'shapes' here is the value from the render when this callback was created
  const newShapes = shapes.filter(s => s.id !== id);
  setShapes(newShapes);
  // If shapes was updated between renders (e.g., by an async operation),
  // this filter runs against stale data — a shape that was deleted in the meantime
  // may appear to be re-added, or a shape that was added may be lost
}, [shapes]); // ← Updating this dep array triggers recreation of deleteShape,
              //   which cascades to all consumers via useMemo/useCallback

// Adding 'shapes' to the dep array is correct but triggers recreation of deleteShape
// on EVERY shapes update — which means every child that receives deleteShape via prop
// re-renders even if its own shapes haven't changed

The solution is setShapes(prev => ...) for simple cases — but for multi-step operations that require reading current state from multiple variables, this pattern breaks down entirely. There is no setMultipleThings(prev => ...).

The domain object solution: methods read this.#state at invocation time — always current:

TYPESCRIPT
// ✅ Domain methods always read current state — no closures, no staleness
class CanvasDocument {
  deleteShape(id: ShapeId): void {
    // this.#shapes is always current — read at the moment the method is called
    if (!this.#shapes.has(id)) throw new DomainError(`Shape ${id} not found`);
    if (this.#shapes.size === 1) throw new DomainError('Cannot delete the last shape');
    this.#shapes.delete(id);
    if (this.#selectedId === id) this.#selectedId = null;
    this.emit('changed');
  }
}

No closures. No stale captures. this.#shapes at call time is the current state.


5. Failure Mode 4: Untestable Business Logic

Consider testing the rule "an undo operation should restore the previously selected shape, not just the shapes array":

TYPESCRIPT
// ❌ Testing this requires mounting a component
test('undo restores selection', async () => {
  const { result } = renderHook(() => useCanvasDocument());
  await act(async () => { result.current.addShape(mockShape); });
  await act(async () => { result.current.selectShape(mockShape.id); });
  await act(async () => { result.current.moveShape(mockShape.id, 10, 10); });
  await act(async () => { result.current.undo(); });
  expect(result.current.shapes[0].x).toBe(mockShape.x);
  expect(result.current.selectedId).toBe(mockShape.id); // Is selection restored?
});

This test: imports renderHook and act from @testing-library/react, requires a DOM environment (jsdom), takes 50–200ms to run, is flaky if async state updates are not properly wrapped in act(), and tests only one code path in a hook that has 40 possible state combinations.

The domain object solution:

TYPESCRIPT
// ✅ Testing domain logic requires zero React infrastructure
test('undo restores selection and position', () => {
  const doc = new CanvasDocument();
  const id = doc.addShape({ type: 'rect', x: 0, y: 0, width: 100, height: 100 });
  doc.selectShape(id);
  doc.moveShape(id, 10, 10);
  doc.undo();

  const snap = doc.getSnapshot();
  expect(snap.shapes[0].x).toBe(0);
  expect(snap.selectedId).toBe(id);
});

Three lines. 2ms. No DOM. No async. No act(). 237 such tests in the series' final suite run in 1.1 seconds.


6. Failure Mode 5: Cross-Component State Leakage via Context

The standard solution to prop-drilling is useContext. The standard solution to stale context values is useMemo. The standard solution to over-rendering from context is splitting into multiple contexts. Each solution adds complexity:

TYPESCRIPT
// ❌ Context explosion — splitting one logical state into multiple contexts to avoid re-renders
const ShapesContext = createContext<ShapeData[]>([]);
const SelectionContext = createContext<string | null>(null);
const ToolContext = createContext<ToolType>('select');
const HistoryContext = createContext<HistoryState>({ canUndo: false, canRedo: false });
const ViewportContext = createContext<ViewportState>({ zoom: 1, pan: { x: 0, y: 0 } });

// Five providers wrapping the app — and they must be in the right order
function App() {
  return (
    <ShapesProvider>
      <SelectionProvider>
        <ToolProvider>
          <HistoryProvider>
            <ViewportProvider>
              <CanvasEditor />
            </ViewportProvider>
          </HistoryProvider>
        </ToolProvider>
      </SelectionProvider>
    </ShapesProvider>
  );
}

Each context has its own update logic, its own consistency requirements with the others, and its own potential for the stale closure and entanglement problems described above. The providers are now a distributed state machine with no central coordinator.

The domain object solution: one domain object, one subscription:

TYPESCRIPT
// ✅ One domain object, subscribed to via useSyncExternalStore
// No context needed — components receive what they need via props from the subscribed root
function App() {
  const [doc] = useState(() => new CanvasDocument());
  const snapshot = useCanvasDocument(doc); // Single subscription

  return (
    <CanvasEditor
      shapes={snapshot.shapes}
      selectedId={snapshot.selectedId}
      activeTool={snapshot.activeTool}
      canUndo={snapshot.canUndo}
      viewport={snapshot.viewport}
      doc={doc}
    />
  );
}

One useSyncExternalStore subscription. One re-render trigger. Props flow down. No context gymnastics.


7. Failure Mode 6: The Impossible Refactor

The most devastating consequence of Hook Soup: at a certain size, the code becomes impossible to refactor safely. Moving business logic from useCanvasDocument to a new useSelectionManager requires:

  1. Understanding which state variables the selection logic depends on
  2. Identifying which state update functions must be shared
  3. Extracting them without breaking the dependency arrays of unrelated callbacks
  4. Verifying that no stale closures were introduced
  5. Running all tests — if tests exist

Steps 1–4 require reading the entire 400-line hook. Step 5 is slow and incomplete because the hook tests use renderHook.

With a domain object, refactoring CanvasDocument to extract SelectionManager is:

TYPESCRIPT
// ✅ Refactoring a domain object: extract a class, delegate methods
class SelectionManager {
  #selectedId: ShapeId | null = null;
  #multiSelectedIds: Set<ShapeId> = new Set();

  select(id: ShapeId): void { this.#selectedId = id; this.#multiSelectedIds.clear(); }
  addToSelection(id: ShapeId): void { this.#multiSelectedIds.add(id); }
  clearSelection(): void { this.#selectedId = null; this.#multiSelectedIds.clear(); }

  get selectedId(): ShapeId | null { return this.#selectedId; }
  get isMultiSelect(): boolean { return this.#multiSelectedIds.size > 1; }
}

class CanvasDocument {
  #selection = new SelectionManager(); // Delegate
  selectShape(id: ShapeId): void { this.#selection.select(id); this.emit('changed'); }
}

The refactor is safe because the domain object's public interface is unchanged — doc.selectShape() still works. React components do not need to change. The only test that changes is the one directly testing selection behavior.


Summary

Failure Mode Root Cause Domain Object Resolution
Entangled State Multiple useState with implicit invariants Invariants enforced by methods; derived state computed, not stored
Effect Cascade useEffect chains producing multiple renders Derived state computed synchronously in getSnapshot()
Stale Closures Callbacks capture state at creation time Methods read this.#state at invocation — always current
Untestable Logic Business rules embedded in hooks that need DOM Domain objects tested with new ClassName() — no React needed
Context Explosion Splitting state to avoid re-renders One domain object, one useSyncExternalStore subscription
Impossible Refactor Business logic entangled with React lifecycle Extract class, delegate methods — React interface unchanged

What's Next

Part 3 goes inside V8's engine: how JavaScript prototypes map to memory, what hidden class transitions are and when they cause performance regressions, and why ES6 class syntax produces more predictable V8 optimization outcomes than factory functions for domain-heavy object creation.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#React#OOP#Architecture#TypeScript#Hooks#State Management#Frontend Design Patterns
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.