Siddhant Deval
Siddhant Deval
frontend5 min read

SOLID Principles in Frontend Engineering: Practical UI Architecture

SOLID principles are not academic dogma for Java backends — they are practical design heuristics that prevent frontend codebases from devolving into brittle, tightly coupled dependency tangles. This article maps every SOLID principle to concrete frontend violations and derives the correct canvas architecture refactoring for each.

SOLID Principles in Frontend Engineering: Practical UI Architecture

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. The five SOLID principles that governed the backend billing engine (Parts 1 and 8 of the backend series) apply with equal force on the frontend — but they manifest differently. SRP is not about separating database access from business logic; it is about separating rendering concerns from domain concerns. OCP is not about adding event handlers without modifying a use case; it is about adding new shape renderers without touching the canvas component. The principles are identical — the layer they govern is different.

This article applies all five SOLID principles to the canvas studio — with concrete violations from frontend React codebases and the refactoring that resolves each one.


1. Single Responsibility Principle: One Reason to Change

SRP in frontend engineering is violated most commonly in React components that own too many concerns and in hooks that combine fetching, caching, and transformation.

1.1 The God Component

TYPESCRIPT
// ❌ SRP Violation: CanvasEditor owns rendering, tool management, keyboard shortcuts,
//    context menus, undo/redo UI, zoom controls, and panel state
function CanvasEditor() {
  const [activeTool, setActiveTool] = useState('select');
  const [zoom, setZoom] = useState(1);
  const [showLayerPanel, setShowLayerPanel] = useState(true);
  const [showPropertiesPanel, setShowPropertiesPanel] = useState(true);
  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
  const [canUndo, setCanUndo] = useState(false);
  const [canRedo, setCanRedo] = useState(false);

  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key === 'z' && e.metaKey) { /* undo */ }
      if (e.key === 'z' && e.metaKey && e.shiftKey) { /* redo */ }
      if (e.key === 'v') setActiveTool('select');
      if (e.key === 'r') setActiveTool('rect');
      // ... 20 more shortcuts
    };
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, []);

  // ... 400 lines of rendering, event handlers, context menu logic, zoom math
}

Seven reasons this component changes: tool selection changes, zoom behavior changes, panel visibility changes, context menu items change, keyboard shortcuts change, undo/redo logic changes, or the canvas rendering changes.

The fix: one component, one reason to change:

TYPESCRIPT
// ✅ SRP: Each component has one responsibility
function CanvasEditor({ doc, tools }: { doc: CanvasDocument; tools: ToolController }) {
  return (
    <div className="canvas-editor">
      <Toolbar tools={tools} />           {/* Owns: tool selection UI */}
      <LayerPanel doc={doc} />            {/* Owns: layer list rendering */}
      <Canvas doc={doc} tools={tools} /> {/* Owns: shape rendering + pointer events */}
      <PropertiesPanel doc={doc} />      {/* Owns: selected shape properties */}
      <ZoomControls doc={doc} />         {/* Owns: zoom/pan UI */}
    </div>
  );
}
// CanvasEditor now only changes when the overall layout changes.
// Keyboard shortcuts: handled in ToolController.handleKeyDown() — domain object
// Undo/redo: handled in CanvasDocument — domain object
// Zero useEffect in CanvasEditor

1.2 SRP in Hooks: Separation of Data Concerns

TYPESCRIPT
// ❌ SRP Violation: hook owns fetching, caching, transformation, and error formatting
function useShapeData(id: ShapeId) {
  const [loading, setLoading] = useState(true);
  const [raw, setRaw] = useState<ApiShapeResponse | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    fetch(`/api/shapes/${id}`)
      .then(r => r.json())
      .then(data => { setRaw(data); setLoading(false); })
      .catch(e => { setError(e.message); setLoading(false); });
  }, [id]);

  // Transformation mixed with fetching
  const shape = raw ? parseShapeId(raw.id) && new RectShape(parseShapeId(raw.id), raw.x, raw.y, raw.width, raw.height) : null;
  const errorMessage = error ? `Failed to load shape: ${error}` : null; // Formatting mixed in

  return { shape, loading, errorMessage };
}

The fix: separate fetch, transform, and error presentation:

TYPESCRIPT
// ✅ SRP: Each hook has one responsibility
// 1. Fetching: raw data only
function useShapeApi(id: ShapeId) {
  return useSWR<ApiShapeResponse>(`/api/shapes/${id}`, fetcher);
}

// 2. Transformation: raw → domain object (pure function, no hook needed)
function parseApiShape(raw: ApiShapeResponse): RectShape {
  return new RectShape(parseShapeId(raw.id), raw.x, raw.y, raw.width, raw.height);
}

// 3. Consumer: composes the two
function useShape(id: ShapeId): { shape: RectShape | null; isLoading: boolean; error: Error | null } {
  const { data, error, isLoading } = useShapeApi(id);
  return {
    shape: data ? parseApiShape(data) : null,
    isLoading,
    error: error ?? null,
  };
}

2. Open/Closed Principle: Extend by Adding, Not by Modifying

OCP in frontend is violated most commonly in the shape renderer — every new shape type opens the renderer file to add a new branch.

2.1 The Closed Renderer Anti-Pattern

TYPESCRIPT
// ❌ OCP Violation: adding a PathShape requires opening this function
function ShapeRenderer({ data }: { data: ShapeData }) {
  switch (data.type) {
    case 'rect':    return <RectRenderer data={data as RectShapeData} />;
    case 'ellipse': return <EllipseRenderer data={data as EllipseShapeData} />;
    case 'text':    return <TextRenderer data={data as TextShapeData} />;
    // ← Adding PathShape opens this file
  }
}

The fix: a renderer registry — open for extension, closed for modification:

TYPESCRIPT
// ✅ OCP: Shape renderers registered, not hard-coded
type ShapeRendererComponent = React.FC<{ data: ShapeData }>;

const rendererRegistry = new Map<string, ShapeRendererComponent>([
  ['rect',    RectRenderer],
  ['ellipse', EllipseRenderer],
  ['text',    TextRenderer],
]);

// Extend without modifying — called at app initialization
export function registerRenderer(type: string, component: ShapeRendererComponent): void {
  rendererRegistry.set(type, component);
}

// The dispatcher never changes regardless of how many shape types are added
export function ShapeRenderer({ data }: { data: ShapeData }) {
  const Component = rendererRegistry.get(data.type);
  if (!Component) return <UnknownShapeRenderer type={data.type} />;
  return <Component data={data} />;
}

Adding PathShape rendering:

TYPESCRIPT
// In the module that introduces PathShape — zero changes to ShapeRenderer.tsx
registerRenderer('path', PathRenderer);

ShapeRenderer.tsx never needs to be opened again.

2.2 OCP in the Tool System

Already established in Part 7: ToolController.registerTool() allows adding new tools without modifying ToolController. The same pattern applies to export formats, keyboard shortcut handlers, and context menu items.


3. Liskov Substitution Principle: Behavioral Compatibility in the Tool Hierarchy

LSP in frontend manifests when a concrete tool implementation changes the behavioral contract of ITool — breaking code that depends on the interface.

3.1 The Silent No-Op Violation

TYPESCRIPT
// ❌ LSP Violation: EyedropperTool.onPointerMove silently ignores movement
class EyedropperTool implements ITool {
  readonly name = 'eyedropper';
  readonly cursor = 'crosshair';

  onPointerDown(event: ToolPointerEvent, doc: CanvasDocument): void {
    const color = doc.sampleColorAt(event.point);
    doc.setFillColor(color);
  }

  onPointerMove(_event: ToolPointerEvent, _doc: CanvasDocument): void {
    // No-op — but callers expect a preview/cursor update
    // The canvas shows no feedback during hover — user thinks the tool is broken
  }

  onPointerUp() {} onCancel() {} activate() {} deactivate() {}
}

The ITool contract implies: during onPointerMove, the tool updates the preview state so the canvas reflects the current interaction. EyedropperTool silently violates this by doing nothing — the cursor and preview do not update.

The fix: honor the behavioral contract:

TYPESCRIPT
// ✅ LSP: EyedropperTool provides hover preview — honors ITool behavioral contract
class EyedropperTool implements ITool {
  onPointerMove(event: ToolPointerEvent, doc: CanvasDocument): void {
    // Show color preview at current point — satisfies the "preview during move" contract
    const color = doc.sampleColorAt(event.point);
    doc.setPreview({ type: 'color-sample', point: event.point, color });
  }
  // ...
}

The ITool contract test (from the testing strategy in Part 12) catches this:

TYPESCRIPT
// Shared LSP contract test — every ITool implementation must pass
export function toolContract(makeTool: () => ITool): void {
  describe('ITool contract', () => {
    it('should update preview during pointer move', () => {
      const tool = makeTool();
      const doc = new CanvasDocument();
      tool.activate(doc);
      tool.onPointerDown({ point: point(0, 0), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
      tool.onPointerMove({ point: point(50, 50), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
      // Contract: after onPointerMove, doc must have EITHER a preview OR a selection change
      const snap = doc.getSnapshot();
      const hadEffect = snap.preview !== null || snap.selectedIds.size > 0;
      expect(hadEffect).toBe(true);
    });

    it('should clear preview on deactivate', () => {
      const tool = makeTool();
      const doc = new CanvasDocument();
      tool.activate(doc);
      tool.onPointerDown({ point: point(0, 0), shiftKey: false, ctrlKey: false, altKey: false, pressure: 1 }, doc);
      tool.deactivate(doc);
      expect(doc.getSnapshot().preview).toBeNull();
    });
  });
}

4. Interface Segregation Principle: Role-Specific Snapshots

ISP in frontend is violated when components receive a fat snapshot with all canvas state — most of which they do not use — causing unnecessary re-renders when unrelated state changes.

4.1 The Fat Snapshot Anti-Pattern

TYPESCRIPT
// ❌ ISP Violation: all components receive the full CanvasSnapshot
interface CanvasSnapshot {
  shapes: ShapeData[];
  selectedIds: Set<ShapeId>;
  activeTool: string;
  zoom: number;
  pan: Point;
  preview: PreviewData | null;
  canUndo: boolean;
  canRedo: boolean;
  layerOrder: LayerId[];
  fillColor: string;
  strokeColor: string;
  // ... 10 more fields
}

// Toolbar only needs activeTool — but receives the entire snapshot
// When shapes change, Toolbar re-renders unnecessarily
function Toolbar({ snapshot }: { snapshot: CanvasSnapshot }) {
  return <ToolButton active={snapshot.activeTool} />;
}

The fix: segregated snapshot interfaces per component role:

TYPESCRIPT
// ✅ ISP: Each component receives only what it consumes
interface ToolbarSnapshot {
  activeTool: string;
  canUndo: boolean;
  canRedo: boolean;
}

interface CanvasViewSnapshot {
  shapes: ShapeData[];
  preview: PreviewData | null;
  zoom: number;
  pan: Point;
}

interface PropertiesPanelSnapshot {
  selectedIds: Set<ShapeId>;
  fillColor: string;
  strokeColor: string;
}

// Components re-render only when their specific slice changes
function Toolbar({ snapshot }: { snapshot: ToolbarSnapshot }) {
  return <ToolButton active={snapshot.activeTool} />;
}
// Toolbar does not re-render when shapes are moved — it does not consume shapes

The domain object provides narrowed selectors:

TYPESCRIPT
class CanvasDocument {
  getToolbarSnapshot(): ToolbarSnapshot {
    return { activeTool: this.#tools.activeTool.name, canUndo: this.#history.canUndo, canRedo: this.#history.canRedo };
  }

  getCanvasViewSnapshot(): CanvasViewSnapshot {
    return {
      shapes: this.#renderOrder.map(id => this.#shapes.get(id)!.toData()),
      preview: this.#preview,
      zoom: this.#viewport.zoom,
      pan: this.#viewport.pan,
    };
  }

  getPropertiesSnapshot(): PropertiesPanelSnapshot {
    return { selectedIds: new Set(this.#selectedIds), fillColor: this.#fillColor, strokeColor: this.#strokeColor };
  }
}

Each component subscribes to its specific selector — useSyncExternalStore (Part 11) uses the selector as the getSnapshot argument, so re-renders are gated on the component's specific data changing.


5. Dependency Inversion Principle: Hooks Depend on Abstractions

DIP in frontend is violated when React hooks import concrete domain classes instead of depending on interfaces — coupling the component layer to the domain implementation.

5.1 The Direct Import Violation

TYPESCRIPT
// ❌ DIP Violation: React hook imports a concrete domain class
import { CanvasDocument } from '../domain/canvas/CanvasDocument';

function useCanvasDocument() {
  const [doc] = useState(() => new CanvasDocument()); // Hook creates the dependency
  return doc;
}

// Problem: if CanvasDocument is replaced by CanvasDocumentV2, every hook changes
// Problem: testing requires constructing a real CanvasDocument — no substitution possible

The fix: hooks receive the domain object via props/context — they do not construct it:

TYPESCRIPT
// ✅ DIP: Hook depends on the interface, not the concrete class
import type { ICanvasDocument } from '../domain/canvas/ICanvasDocument';

// Domain interface — what the hook needs
export interface ICanvasDocument {
  subscribe(listener: () => void): () => void;
  getSnapshot(): CanvasSnapshot;
  addShape(shape: IShape): void;
  selectShape(id: ShapeId): void;
  // ... other operations
}

// Hook depends on the interface — injected, not constructed
function useCanvasDocument(doc: ICanvasDocument): CanvasSnapshot {
  return useSyncExternalStore(
    (notify) => doc.subscribe(notify),
    () => doc.getSnapshot(),
  );
}

// Canvas component receives the doc — doesn't create it
function Canvas({ doc }: { doc: ICanvasDocument }) {
  const snapshot = useCanvasDocument(doc);
  return <svg>{/* ... */}</svg>;
}

// In tests: pass a mock/stub that satisfies ICanvasDocument
const mockDoc: ICanvasDocument = {
  subscribe: (fn) => { return () => {}; },
  getSnapshot: () => ({ shapes: [], selectedIds: new Set(), /* ... */ }),
  addShape: jest.fn(),
  selectShape: jest.fn(),
};
render(<Canvas doc={mockDoc} />);

The Canvas component tests now require only a plain object satisfying ICanvasDocument — not a full CanvasDocument instance with all its dependencies.


Summary

Principle Frontend Manifestation Resolution
SRP God component: renders + manages tools + handles shortcuts Split into Canvas, Toolbar, LayerPanel, ZoomControls — each one reason to change
SRP Hook that fetches + transforms + formats errors Separate useShapeApi, parseApiShape, useShape
OCP ShapeRenderer switch opened for every new shape type Renderer registry: registerRenderer('path', PathRenderer) — zero existing changes
LSP EyedropperTool.onPointerMove silent no-op violates preview contract toolContract shared test suite catches behavioral violations
ISP All components receive full CanvasSnapshot — unnecessary re-renders Segregated snapshots: ToolbarSnapshot, CanvasViewSnapshot, PropertiesPanelSnapshot
DIP Hook constructs new CanvasDocument() — coupled to concrete class Hook receives ICanvasDocument interface — testing requires only a plain object stub

What's Next

Part 9 implements the SelectionState finite state machine and the Observer Pattern — decoupling the toolbar, properties panel, and canvas from each other so that selection changes propagate without direct component coupling or shared context.

Research & Synthesis Note

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

#SOLID#OOP#TypeScript#Architecture#Frontend Design Patterns#Dependency Inversion#React
Siddhant Deval

Written by Siddhant Deval

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