Siddhant Deval
Siddhant Deval
frontend5 min read

Mental Model Shift: Thinking in Domain Objects Instead of Re-Renders

Senior frontend engineering requires separating ephemeral view state from durable domain aggregates. Instead of asking 'How do I re-render this component?', the right question is 'What domain entity owns this invariant?' This article introduces the mental shift that makes complex UIs manageable.

Mental Model Shift: Thinking in Domain Objects Instead of Re-Renders

React teaches you to think in state. Every piece of UI is a function of some state. When state changes, the component re-renders. This mental model works well for forms, toggles, counters, and data tables. It starts to break down around session 40 of building a complex editor — when you have fifteen useState calls, twelve useCallback memos, six useEffect side effects, and a growing conviction that something structural is wrong.

The structural problem is not React. It is the absence of domain modeling. React's state primitives — useState, useReducer, useContext — are UI state primitives. They answer the question: what should the component render right now? They do not answer the question: what are the business rules and invariants that govern how this data can change? When complex business logic is expressed entirely in React state, the logic becomes inseparable from the UI layer. A vector drawing tool's constraint that "you cannot delete the last layer" becomes a boolean check inside an onClick handler instead of a domain rule that throws an error at its boundary.

This article establishes the mental model for the entire series: what domain objects are in a frontend context, why they are different from React state, and how the two coexist without coupling.


1. The Two Mental Models

1.1 The React Mental Model: UI as a Function of State

The React mental model is:

UI = f(state)

When state changes, f is called again. The output is a new virtual DOM tree. React diffs it against the previous tree and applies the minimum set of DOM mutations.

This model is correct and powerful — for rendering. The problem is when business logic is collapsed into this model:

TYPESCRIPT
// ❌ Business logic collapsed into React state — the common pattern
function CanvasEditor() {
  const [shapes, setShapes] = useState<Shape[]>([]);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [activeTool, setActiveTool] = useState<'select' | 'rect' | 'ellipse' | 'pen'>('select');
  const [history, setHistory] = useState<Shape[][]>([]);
  const [historyIndex, setHistoryIndex] = useState(0);
  const [zoom, setZoom] = useState(1);
  const [pan, setPan] = useState({ x: 0, y: 0 });
  const [isDrawing, setIsDrawing] = useState(false);
  const [drawStart, setDrawStart] = useState<{ x: number; y: number } | null>(null);

  const deleteShape = useCallback(() => {
    if (!selectedId) return;
    // ❌ Business rule buried in event handler
    if (shapes.length === 1) return; // Can't delete last shape — but where is this rule documented?
    const newShapes = shapes.filter(s => s.id !== selectedId);
    setHistory(prev => [...prev.slice(0, historyIndex + 1), newShapes]);
    setHistoryIndex(i => i + 1);
    setShapes(newShapes);
    setSelectedId(null);
  }, [shapes, selectedId, historyIndex]);

  // ... 300 more lines
}

This component has accumulated state for: shapes, selection, active tool, undo history, viewport (zoom + pan), and drawing interaction. Eight state variables that are interdependent. The business rule "cannot delete the last shape" is a comment in a useCallback. The undo history logic is interleaved with the shape mutation logic. Testing this requires mounting the component.

1.2 The Domain Object Mental Model: Behavior at Boundaries

The domain object mental model separates what the data is and what rules govern how it changes (domain objects) from how that data is displayed (React state):

Domain Object = State + Behavior + Invariant Enforcement
UI = f(domain_object.snapshot())

The same canvas editor, with the business logic extracted into a domain object:

TYPESCRIPT
// ✅ Domain object owns behavior and invariants — React owns rendering
class CanvasDocument {
  #shapes: Shape[] = [];
  #selectedId: string | null = null;

  deleteSelected(): void {
    if (!this.#selectedId) throw new DomainError('No shape selected');
    if (this.#shapes.length === 1)
      throw new DomainError('Cannot delete the last shape in the document');

    this.#shapes = this.#shapes.filter(s => s.id !== this.#selectedId);
    this.#selectedId = null;
    this.emit('shapesChanged');
  }
}

// React component is minimal — it subscribes and renders
function CanvasEditor() {
  const [snapshot, setSnapshot] = useState(() => document.getSnapshot());

  useEffect(() => {
    const unsub = document.on('shapesChanged', () => setSnapshot(document.getSnapshot()));
    return unsub;
  }, []);

  return <Canvas shapes={snapshot.shapes} selectedId={snapshot.selectedId} />;
}

The business rule "cannot delete the last shape" now lives in CanvasDocument.deleteSelected(). It is testable without React:

TYPESCRIPT
const doc = new CanvasDocument();
doc.addShape(rectShape);
expect(() => doc.deleteSelected()).toThrow('No shape selected');
doc.selectShape(rectShape.id);
expect(() => doc.deleteSelected()).toThrow('Cannot delete the last shape');

Zero component rendering. Zero act(). Zero waitFor(). The domain rule is proven correct in a 5ms unit test.


2. What Makes a Frontend Domain Object Different

Backend domain objects (Parts 3–14 of the backend series) are built around the Aggregate pattern: they enforce invariants, collect Domain Events, and are persisted by Repository adapters. Frontend domain objects share the invariant-enforcement structure but differ in three ways:

2.1 They Are Always In-Memory

Backend domain objects are loaded from and saved to a database. Frontend domain objects live in the browser's memory for the duration of a session. They may be serialized to localStorage, IndexedDB, or sent to a server — but they are always reconstituted in memory on the client. This means:

  • No async factories (construction is synchronous)
  • No repository interfaces (persistence is a separate concern, usually handled by a separate service class)
  • Lifecycle is bounded by the browser tab

2.2 They Must Be Observable by React

React re-renders components when state changes. Domain objects encapsulate their state privately. Bridging these two requires an Observable pattern — domain objects emit events when their state changes, and React subscribes to those events. This is the topic of Part 11 (useSyncExternalStore). For now, the key rule: domain objects must not import React, and React components must not directly read domain object internals.

2.3 They Represent Interaction State, Not Just Data

Backend domain objects model business entities: Order, Customer, Invoice. Frontend domain objects often model interaction state: SelectionManager, ToolController, ViewportState, UndoHistory. These do not correspond to database tables — they model the rich, stateful interaction layer of a complex UI.


3. The Vector Studio: The Series' Domain

Every article in this series builds toward one application: a browser-based vector drawing tool. Think Figma's core editing loop — shapes on a canvas, a tool palette, selection, multi-select, undo/redo history, and zoom/pan viewport control.

This domain is chosen because it naturally requires every OOP concept we cover:

Concept Canvas Domain Application
Encapsulation + #private Shape internals inaccessible — position only changed through move()
Branded primitives ShapeId, LayerId — cross-ID confusion is a compile-time error
Composition over inheritance RectShape, EllipseShape, GroupShape share behavior via composition, not class hierarchy
Polymorphism / Strategy SelectTool, RectTool, PenTool implement ITool — no switch (tool) in event handlers
State machines Selection state: IDLE → SELECTING → SELECTED → MULTI_SELECTED
Observer CanvasDocument emits shapesChanged; toolbar listens without coupling
Command + Memento MoveCommand, ResizeCommand implement ICommand; UndoHistory replays them
useSyncExternalStore React subscribes to CanvasDocument without React inside the domain

By Part 12, all 11 patterns are assembled into a working studio that handles 10,000 shapes at 60fps.

3.1 The Core Domain Types (Established in Part 1)

TYPESCRIPT
// src/domain/shared/brand.ts — Branded primitive factory (Part 5 covers this fully)
declare const __brand: unique symbol;
export type Brand<T, B extends string> = T & { readonly [__brand]: B };

export type ShapeId   = Brand<string, 'ShapeId'>;
export type LayerId   = Brand<string, 'LayerId'>;
export type CommandId = Brand<string, 'CommandId'>;

export const makeShapeId   = (): ShapeId   => `shape_${crypto.randomUUID()}`  as ShapeId;
export const makeLayerId   = (): LayerId   => `layer_${crypto.randomUUID()}`  as LayerId;
export const makeCommandId = (): CommandId => `cmd_${crypto.randomUUID()}`    as CommandId;
TYPESCRIPT
// src/domain/geometry/Point.ts — Immutable Value Object
export interface Point {
  readonly x: number;
  readonly y: number;
}

export const point = (x: number, y: number): Point => ({ x, y });

export const addPoints    = (a: Point, b: Point): Point => ({ x: a.x + b.x, y: a.y + b.y });
export const subtractPoints = (a: Point, b: Point): Point => ({ x: a.x - b.x, y: a.y - b.y });
export const scalePoint   = (p: Point, factor: number): Point => ({ x: p.x * factor, y: p.y * factor });
TYPESCRIPT
// src/domain/geometry/Rect.ts — Bounding box Value Object
export interface Rect {
  readonly x: number;
  readonly y: number;
  readonly width: number;
  readonly height: number;
}

export const rect = (x: number, y: number, w: number, h: number): Rect =>
  ({ x, y, width: w, height: h });

export const containsPoint = (r: Rect, p: Point): boolean =>
  p.x >= r.x && p.x <= r.x + r.width &&
  p.y >= r.y && p.y <= r.y + r.height;

export const intersects = (a: Rect, b: Rect): boolean =>
  a.x < b.x + b.width && a.x + a.width > b.x &&
  a.y < b.y + b.height && a.y + a.height > b.y;

These types are plain interfaces (not classes) — Value Objects in the frontend context are lightweight. They are immutable by convention (all fields readonly) and by Object.freeze() in the factories.


4. The Shift in Practice: Before and After

The most common objection to extracting domain objects from React state is: "Won't I have to rewrite everything twice — once in the domain object and once to synchronize it with React?"

The answer is no — the React component becomes dramatically simpler:

TYPESCRIPT
// ❌ BEFORE: Hook Soup (Part 2 dissects this fully)
function useCanvasDocument() {
  const [shapes, setShapes] = useState<ShapeData[]>([]);
  const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
  const [history, setHistory] = useState<{ past: ShapeData[][]; future: ShapeData[][] }>({ past: [], future: [] });

  const moveShape = useCallback((id: string, dx: number, dy: number) => {
    setShapes(prev => {
      const updated = prev.map(s => s.id === id ? { ...s, x: s.x + dx, y: s.y + dy } : s);
      setHistory(h => ({ past: [...h.past, prev], future: [] }));
      return updated;
    });
  }, []);

  const undo = useCallback(() => {
    setHistory(h => {
      if (h.past.length === 0) return h;
      const [previous, ...rest] = h.past.slice().reverse();
      setShapes(previous);
      return { past: rest.slice().reverse(), future: [shapes, ...h.future] };
    });
  }, [shapes]);
  // ... 200 more lines
}
TYPESCRIPT
// ✅ AFTER: Domain object owns logic — hook is a thin subscription
function useCanvasDocument(doc: CanvasDocument) {
  return useSyncExternalStore(
    (notify) => doc.subscribe(notify),
    () => doc.getSnapshot(),
  );
}

// Usage:
function CanvasEditor({ doc }: { doc: CanvasDocument }) {
  const snapshot = useCanvasDocument(doc);
  return (
    <Canvas
      shapes={snapshot.shapes}
      selectedIds={snapshot.selectedIds}
      onMove={(id, dx, dy) => doc.moveShape(id as ShapeId, dx, dy)}
      onUndo={() => doc.undo()}
    />
  );
}

The React component went from 200 lines of business logic to 10 lines of pure presentation. CanvasDocument owns all the logic. Part 11 covers useSyncExternalStore in detail — but the point here is the structural outcome: the component is a view, and the view is thin.


5. Three Rules for Frontend Domain Objects

These rules apply throughout the series:

Rule 1: Domain objects never import React. CanvasDocument.ts has zero imports from react, react-dom, or any React ecosystem library. It is a pure TypeScript class. This ensures it can be tested without a render environment and could run in a Web Worker (Part 12 uses this for performance).

Rule 2: React components never read domain object internals. Components receive only snapshots — plain serializable objects returned by doc.getSnapshot(). They never call doc.#shapes or access any private field. The component knows the shape of the snapshot, not the shape of the domain object.

Rule 3: Mutations go through domain object methods, never through direct state setters. doc.moveShape(), never setShapes(...). doc.selectShape(), never setSelectedId(...). This ensures invariants are always enforced regardless of which component triggers the mutation.


Summary

Concept React Mental Model Domain Object Mental Model
State location useState / useReducer inside component Private fields inside class
Business rules if statements in event handlers Methods that throw DomainError
Testability Requires component mount + act() Plain new ClassName() + method calls
React coupling Deep — state IS React state Zero — domain objects import nothing from React
Snapshot access State variables read directly doc.getSnapshot() returns plain object

What's Next

Part 2 dissects the Hook Soup Anti-Pattern in detail — naming every failure mode that emerges when complex React apps are built without OOP structure, and diagnosing the exact inflection point at which the pattern collapses.

Research & Synthesis Note

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

#OOP#Domain-Driven Design#React#TypeScript#Architecture#Aggregate Root#Frontend Design Patterns
Siddhant Deval

Written by Siddhant Deval

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