Learn how to transform a monolithic drawing application into a clean, extensible tool system using object-oriented design patterns, custom hooks, and reusable components.
Refactoring React Three Fiber Drawing Tools: From Monolith to Modular Architecture
In my previous post, we built a functional drawing application with React Three Fiber. However, the code suffered from a common problem: monolithic architecture. All tool logic was hardcoded into conditional statements, making it difficult to maintain and extend.
Today, we'll refactor this application into a clean, modular system that's easy to extend, test, and maintain.
The Problem with Monolithic Drawing Tools
Our original implementation had several issues:
// The old way - hardcoded conditionals everywhere
if (currentTool === 'line') {
  // 20+ lines of line tool logic
} else if (currentTool === 'shape') {
  // 30+ lines of shape tool logic  
} else if (currentTool === 'rectangle') {
  // 25+ lines of rectangle tool logic
}
Problems:
- Rigid: Adding new tools requires modifying existing code
- Duplicated Logic: Similar patterns repeated across tools
- Hard to Test: Tool logic mixed with UI concerns
- Poor Separation: State management scattered throughout components
Demo: Before vs After
"use client"
import { Canvas, useFrame, useThree } from '@react-three/fiber'
import { Sphere, Line, Text } from '@react-three/drei'
import { Leva, useControls, button } from 'leva'
import { StrictMode, useRef, useState, useCallback, useEffect } from 'react'
import * as THREE from 'three'
const calculateDistance = (p1, p2) => {
  return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2))
}
export default function App() {
  return (
    <StrictMode>
      <Leva flat />
      <Canvas orthographic camera={{ zoom: 50, near: 0.1, far: 200, position: [0, 0, 15] }}>
        <Experience />
      </Canvas>
    </StrictMode>
  )
}
export function Experience() {
  const [drawnObjects, setDrawnObjects] = useState([])
  const [currentTool, setCurrentTool] = useState('line')
  const [isDrawing, setIsDrawing] = useState(false)
  const [drawingState, setDrawingState] = useState(null)
  const [cursorPosition, setCursorPosition] = useState(null)
  const { tool, snapEnabled, snapValue, showLengths } = useControls('Drawing Tools', {
    tool: { 
      value: 'line', 
      options: { Line: 'line', Shape: 'shape', Rectangle: 'rectangle', Delete: 'delete' },
      onChange: (value) => {
        setCurrentTool(value)
        setIsDrawing(false)
        setDrawingState(null)
      }
    },
    snapEnabled: true,
    snapValue: { value: 0.5, min: 0.1, max: 2, step: 0.1 },
    showLengths: false,
    clear: button(() => {
      setDrawnObjects([])
      setIsDrawing(false)
      setDrawingState(null)
    })
  })
  const snapToGrid = useCallback((point) => {
    if (!snapEnabled) return point
    return {
      x: Math.round(point.x / snapValue) * snapValue,
      y: Math.round(point.y / snapValue) * snapValue,
      z: 0
    }
  }, [snapEnabled, snapValue])
  const handleObjectDelete = useCallback((objectId) => {
    setDrawnObjects(prev => prev.filter(obj => obj.id !== objectId))
  }, [])
  const handleCanvasClick = useCallback((point) => {
    if (currentTool === 'delete') return 
    
    const snappedPoint = snapToGrid(point)
    
    if (currentTool === 'line') {
      if (!isDrawing) {
        setIsDrawing(true)
        setDrawingState({ start: snappedPoint, end: snappedPoint })
      } else {
        setDrawnObjects(prev => [...prev, {
          type: 'line',
          id: Date.now(),
          start: drawingState.start,
          end: snappedPoint
        }])
        setIsDrawing(false)
        setDrawingState(null)
      }
    } else if (currentTool === 'shape') {
      if (!isDrawing) {
        setIsDrawing(true)
        setDrawingState({ points: [snappedPoint] })
      } else {
        const newPoints = [...drawingState.points, snappedPoint]
        setDrawingState({ points: newPoints })
      }
    } else if (currentTool === 'rectangle') {
      if (!isDrawing) {
        setIsDrawing(true)
        setDrawingState({ corner1: snappedPoint, corner2: snappedPoint })
      } else {
        setDrawnObjects(prev => [...prev, {
          type: 'rectangle',
          id: Date.now(),
          corner1: drawingState.corner1,
          corner2: snappedPoint
        }])
        setIsDrawing(false)
        setDrawingState(null)
      }
    }
  }, [currentTool, isDrawing, drawingState, snapToGrid])
  const handleMouseMove = useCallback((point) => {
    const snappedPoint = snapToGrid(point)
    setCursorPosition(snappedPoint)
    
    if (!isDrawing || !drawingState) return
    
    if (currentTool === 'line') {
      setDrawingState(prev => ({ ...prev, end: snappedPoint }))
    } else if (currentTool === 'rectangle') {
      setDrawingState(prev => ({ ...prev, corner2: snappedPoint }))
    }
  }, [isDrawing, drawingState, currentTool, snapToGrid])
  const handleShapeComplete = useCallback(() => {
    if (currentTool === 'shape' && isDrawing && drawingState?.points.length >= 3) {
      setDrawnObjects(prev => [...prev, {
        type: 'shape',
        id: Date.now(),
        points: drawingState.points
      }])
      setIsDrawing(false)
      setDrawingState(null)
    }
  }, [currentTool, isDrawing, drawingState])
  useEffect(() => {
    const handleKeyDown = (e) => {
      if (e.key === 'Escape') {
        if (currentTool === 'shape') {
          handleShapeComplete()
        } else {
          setIsDrawing(false)
          setDrawingState(null)
        }
      }
    }
    
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [currentTool, handleShapeComplete])
  return (
    <>
      <directionalLight position={[1, 2, 3]} intensity={1.5} />
      <ambientLight intensity={0.5} />
      
      <DrawingCanvas 
        onCanvasClick={handleCanvasClick}
        onMouseMove={handleMouseMove}
        snapEnabled={snapEnabled}
        snapValue={snapValue}
      />
      
      {}
      {drawnObjects.map(obj => (
        <DrawnObject 
          key={obj.id} 
          object={obj} 
          isDeleteMode={currentTool === 'delete'}
          showLengths={showLengths}
          onDelete={() => handleObjectDelete(obj.id)}
        />
      ))}
      
      {}
      {isDrawing && drawingState && (
        <DrawingPreview 
          tool={currentTool} 
          state={drawingState} 
          cursorPosition={cursorPosition}
          showLengths={showLengths}
          onShapeComplete={handleShapeComplete}
        />
      )}
      
      <GridHelper snapValue={snapValue} visible={snapEnabled} />
    </>
  )
}
export function DrawingCanvas({ onCanvasClick, onMouseMove, snapEnabled, snapValue }) {
  const cursorRef = useRef()
  const planeRef = useRef()
  return (
    <mesh
      ref={planeRef}
      position={[0, 0, 0]}
      onPointerMove={(e) => {
        const localPoint = planeRef.current.worldToLocal(e.point.clone())
        
        if (snapEnabled) {
          localPoint.x = Math.round(localPoint.x / snapValue) * snapValue
          localPoint.y = Math.round(localPoint.y / snapValue) * snapValue
        }
        
        if (cursorRef.current) {
          cursorRef.current.position.set(localPoint.x, localPoint.y, 0.01)
          cursorRef.current.visible = true
        }
        
        
        const drawingPoint = { x: localPoint.x, y: localPoint.y, z: 0 }
        onMouseMove(drawingPoint)
      }}
      onPointerOut={() => {
        if (cursorRef.current) {
          cursorRef.current.visible = false
        }
      }}
      onClick={(e) => {
        const localPoint = planeRef.current.worldToLocal(e.point.clone())
        
        if (snapEnabled) {
          localPoint.x = Math.round(localPoint.x / snapValue) * snapValue
          localPoint.y = Math.round(localPoint.y / snapValue) * snapValue
        }
        
        
        const drawingPoint = { x: localPoint.x, y: localPoint.y, z: 0 }
        onCanvasClick(drawingPoint)
      }}
    >
      <planeGeometry args={[20, 20]} />
      <meshBasicMaterial transparent opacity={0.1} color="lightblue" />
      
      <CursorIndicator ref={cursorRef} />
    </mesh>
  )
}
export const CursorIndicator = ({ color = "orange", size = 0.1, ...props }) => {
  const ref = useRef()
  
  useFrame(() => {
    if (ref.current) {
      ref.current.rotation.x += 0.01
      ref.current.rotation.y += 0.01
    }
  })
  
  return (
    <mesh ref={ref} raycast={() => null} visible={false} {...props}>
      <sphereGeometry args={[size]} />
      <meshBasicMaterial color={color} />
    </mesh>
  )
}
export function DrawnObject({ object, isDeleteMode, showLengths, onDelete }) {
  const [isHovered, setIsHovered] = useState(false)
  
  const commonProps = isDeleteMode ? {
    onPointerEnter: () => setIsHovered(true),
    onPointerLeave: () => setIsHovered(false),
    onClick: (e) => {
      e.stopPropagation()
      onDelete()
    },
    style: { cursor: 'pointer' }
  } : {}
  
  const color = isDeleteMode && isHovered ? 'red' : undefined
  const opacity = isDeleteMode && isHovered ? 0.8 : 1
  
  switch (object.type) {
    case 'line':
      return <DrawnLine start={object.start} end={object.end} color={color} opacity={opacity} showLengths={showLengths} {...commonProps} />
    case 'shape':
      return <DrawnShape points={object.points} color={color} opacity={opacity} showLengths={showLengths} {...commonProps} />
    case 'rectangle':
      return <DrawnRectangle corner1={object.corner1} corner2={object.corner2} color={color} opacity={opacity} showLengths={showLengths} {...commonProps} />
    default:
      return null
  }
}
export function DrawingPreview({ tool, state, cursorPosition, showLengths, onShapeComplete }) {
  switch (tool) {
    case 'line':
      return state.start && state.end ? (
        <DrawnLine start={state.start} end={state.end} color="orange" opacity={0.7} showLengths={showLengths} />
      ) : null
    case 'shape':
      return (
        <>
          {state.points?.map((point, i) => (
            <mesh key={i} position={[point.x, point.y, 0]}>
              <sphereGeometry args={[0.05]} />
              <meshBasicMaterial color="orange" />
            </mesh>
          ))}
          {state.points?.length > 1 && (
            <>
              <Line 
                points={state.points.map(p => [p.x, p.y, 0])}
                color="orange"
                lineWidth={2}
              />
              {}
              {showLengths && state.points.slice(0, -1).map((point, i) => {
                const nextPoint = state.points[i + 1]
                const segmentLength = calculateDistance(point, nextPoint)
                const midpoint = {
                  x: (point.x + nextPoint.x) / 2,
                  y: (point.y + nextPoint.y) / 2
                }
                return (
                  <Text
                    key={i}
                    position={[midpoint.x, midpoint.y, 0.1]}
                    fontSize={0.2}
                    color="black"
                    anchorX="center"
                    anchorY="middle"
                  >
                    {segmentLength.toFixed(2)}
                  </Text>
                )
              })}
            </>
          )}
          {}
          {state.points?.length > 0 && cursorPosition && (
            <>
              <Line 
                points={[
                  [state.points[state.points.length - 1].x, state.points[state.points.length - 1].y, 0],
                  [cursorPosition.x, cursorPosition.y, 0]
                ]}
                color="orange"
                lineWidth={1}
                transparent
                opacity={0.5}
              />
              {showLengths && (() => {
                const lastPoint = state.points[state.points.length - 1]
                const previewLength = calculateDistance(lastPoint, cursorPosition)
                const midpoint = {
                  x: (lastPoint.x + cursorPosition.x) / 2,
                  y: (lastPoint.y + cursorPosition.y) / 2
                }
                return (
                  <Text
                    position={[midpoint.x, midpoint.y, 0.1]}
                    fontSize={0.2}
                    color="orange"
                    anchorX="center"
                    anchorY="middle"
                  >
                    {previewLength.toFixed(2)}
                  </Text>
                )
              })()}
            </>
          )}
          {state.points?.length >= 3 && (
            <mesh 
              position={[state.points[0].x, state.points[0].y, 0]}
              onClick={onShapeComplete}
            >
              <sphereGeometry args={[0.1]} />
              <meshBasicMaterial color="green" />
            </mesh>
          )}
        </>
      )
    case 'rectangle':
      return state.corner1 && state.corner2 ? (
        <DrawnRectangle corner1={state.corner1} corner2={state.corner2} color="orange" opacity={0.7} showLengths={showLengths} />
      ) : null
    default:
      return null
  }
}
export function DrawnLine({ start, end, color = "black", opacity = 1, showLengths = false, ...props }) {
  const points = [[start.x, start.y, 0], [end.x, end.y, 0]]
  const length = calculateDistance(start, end)
  const midpoint = {
    x: (start.x + end.x) / 2,
    y: (start.y + end.y) / 2
  }
  
  return (
    <group {...props}>
      <Line points={points} color={color} lineWidth={3} />
      <mesh position={[start.x, start.y, 0]}>
        <sphereGeometry args={[0.05]} />
        <meshBasicMaterial color={color} transparent opacity={opacity} />
      </mesh>
      <mesh position={[end.x, end.y, 0]}>
        <sphereGeometry args={[0.05]} />
        <meshBasicMaterial color={color} transparent opacity={opacity} />
      </mesh>
      {showLengths && (
        <Text
          position={[midpoint.x, midpoint.y, 0.1]}
          fontSize={0.2}
          color="black"
          anchorX="center"
          anchorY="middle"
        >
          {length.toFixed(2)}
        </Text>
      )}
    </group>
  )
}
export function DrawnShape({ points, color = "crimson", opacity = 1, showLengths = false, ...props }) {
  if (points.length < 3) return null
  
  const shape = new THREE.Shape()
  shape.moveTo(points[0].x, points[0].y)
  points.forEach(p => shape.lineTo(p.x, p.y))
  shape.lineTo(points[0].x, points[0].y)
  
  
  const segments = []
  for (let i = 0; i < points.length; i++) {
    const start = points[i]
    const end = points[(i + 1) % points.length] 
    segments.push({ start, end })
  }
  
  return (
    <group {...props}>
      <mesh position={[0, 0, 0]}>
        <shapeGeometry args={[shape]} />
        <meshBasicMaterial 
          color={color} 
          transparent 
          opacity={opacity} 
          side={THREE.DoubleSide} 
        />
      </mesh>
      {}
      {segments.map((segment, i) => (
        <Line 
          key={`edge-${i}`}
          points={[[segment.start.x, segment.start.y, 0.01], [segment.end.x, segment.end.y, 0.01]]}
          color="black"
          lineWidth={1}
        />
      ))}
      {showLengths && segments.map((segment, i) => {
        const length = calculateDistance(segment.start, segment.end)
        const midpoint = {
          x: (segment.start.x + segment.end.x) / 2,
          y: (segment.start.y + segment.end.y) / 2
        }
        return (
          <Text
            key={i}
            position={[midpoint.x, midpoint.y, 0.1]}
            fontSize={0.2}
            color="black"
            anchorX="center"
            anchorY="middle"
          >
            {length.toFixed(2)}
          </Text>
        )
      })}
    </group>
  )
}
export function DrawnRectangle({ corner1, corner2, color = "blue", opacity = 1, showLengths = false, ...props }) {
  const width = Math.abs(corner2.x - corner1.x)
  const height = Math.abs(corner2.y - corner1.y)
  const centerX = (corner1.x + corner2.x) / 2
  const centerY = (corner1.y + corner2.y) / 2
  
  
  const minX = Math.min(corner1.x, corner2.x)
  const maxX = Math.max(corner1.x, corner2.x)
  const minY = Math.min(corner1.y, corner2.y)
  const maxY = Math.max(corner1.y, corner2.y)
  
  
  const sides = [
    { start: { x: minX, y: minY }, end: { x: maxX, y: minY }, length: width }, 
    { start: { x: maxX, y: minY }, end: { x: maxX, y: maxY }, length: height }, 
  ]
  
  return (
    <group {...props}>
      <mesh position={[centerX, centerY, 0]}>
        <planeGeometry args={[width, height]} />
        <meshBasicMaterial 
          color={color} 
          transparent 
          opacity={opacity} 
          side={THREE.DoubleSide} 
        />
      </mesh>
      {}
      <Line 
        points={[
          [minX, minY, 0.01], [maxX, minY, 0.01], 
          [maxX, maxY, 0.01], [minX, maxY, 0.01], 
          [minX, minY, 0.01] 
        ]}
        color="black"
        lineWidth={1}
      />
      {showLengths && sides.map((side, i) => {
        const midpoint = {
          x: (side.start.x + side.end.x) / 2,
          y: (side.start.y + side.end.y) / 2
        }
        return (
          <Text
            key={i}
            position={[midpoint.x, midpoint.y, 0.1]}
            fontSize={0.2}
            color="black"
            anchorX="center"
            anchorY="middle"
          >
            {side.length.toFixed(2)}
          </Text>
        )
      })}
    </group>
  )
}
export function GridHelper({ snapValue, visible }) {
  if (!visible) return null
  
  const lines = []
  const size = 20
  const divisions = size / snapValue
  
  for (let i = -divisions; i <= divisions; i++) {
    const pos = i * snapValue
    lines.push(
      <Line 
        key={`h${i}`}
        points={[[-size/2, pos, 0], [size/2, pos, 0]]}
        color="gray"
        lineWidth={0.5}
      />,
      <Line 
        key={`v${i}`}
        points={[[pos, -size/2, 0], [pos, size/2, 0]]}
        color="gray"
        lineWidth={0.5}
      />
    )
  }
  
  return <group>{lines}</group>
}Original monolithic implementation
"use client"
import { Canvas, useFrame } from '@react-three/fiber'
import { Sphere, Line } from '@react-three/drei'
import * as THREE from 'three'
import { Leva, useControls, button } from 'leva'
import { StrictMode, useRef, useEffect, memo } from 'react'
import { useDrawingState, useSnapToGrid, useCoordinateTransform, useCursorIndicator, useKeyboardEvents } from './hooks.js'
import { DrawableObject } from './DrawableObjects.jsx'
import { ToolPreview } from './ToolPreview.jsx'
export default function App() {
  return (
    <StrictMode>
      <Leva flat />
      <Canvas orthographic camera={{ zoom: 50, near: 0.1, far: 200, position: [0, 0, 15] }}>
        <Experience />
      </Canvas>
    </StrictMode>
  )
}
export function Experience() {
  
  const {
    drawnObjects,
    currentTool,
    isDrawing,
    drawingState,
    cursorPosition,
    handleToolChange,
    handleCanvasClick,
    handleMouseMove,
    handleKeyPress,
    handleObjectDelete,
    clearAll,
    toolOptions,
    toolSpecificData
  } = useDrawingState()
  const { snapEnabled, snapValue, setSnapEnabled, setSnapValue } = useSnapToGrid(0.5)
  const { transformPoint } = useCoordinateTransform()
  
  const { tool, snap, snapVal, showLengths } = useControls('Drawing Tools (Refactored)', {
    tool: {
      value: 'line',
      options: toolOptions,
      onChange: handleToolChange
    },
    snap: {
      value: true,
      onChange: setSnapEnabled
    },
    snapVal: {
      value: 0.5,
      min: 0.1,
      max: 2,
      step: 0.1,
      onChange: setSnapValue
    },
    showLengths: false,
    clear: button(clearAll)
  })
  
  useKeyboardEvents(handleKeyPress)
  return (
    <>
      <directionalLight position={[1, 2, 3]} intensity={1.5} />
      <ambientLight intensity={0.5} />
      <DrawingCanvas
        onCanvasClick={handleCanvasClick}
        onMouseMove={handleMouseMove}
        snapEnabled={snapEnabled}
        snapValue={snapValue}
        transformPoint={transformPoint}
      />
      {}
      {drawnObjects.map(obj => (
        <DrawableObject
          key={obj.id}
          object={obj}
          isDeleteMode={currentTool === 'delete'}
          showLengths={showLengths}
          onDelete={() => handleObjectDelete(obj.id)}
        />
      ))}
      {}
      {isDrawing && drawingState && (
        <ToolPreview
          tool={currentTool}
          state={drawingState}
          cursorPosition={cursorPosition}
          showLengths={showLengths}
          onShapeComplete={() => handleKeyPress('Escape')}
        />
      )}
      <GridHelper snapValue={snapValue} visible={snapEnabled} />
    </>
  )
}
export function DrawingCanvas({ onCanvasClick, onMouseMove, snapEnabled, snapValue, transformPoint }) {
  const { cursorRef, showCursor, hideCursor } = useCursorIndicator()
  const planeRef = useRef()
  return (
    <mesh
      ref={planeRef}
      position={[0, 0, 0]}
      onPointerMove={(e) => {
        if (!planeRef.current) return
        const localPoint = planeRef.current.worldToLocal(e.point.clone())
        const transformedPoint = transformPoint(localPoint, snapEnabled, snapValue)
        showCursor(transformedPoint)
        onMouseMove(transformedPoint, snapEnabled, snapValue)
      }}
      onPointerOut={hideCursor}
      onClick={(e) => {
        if (!planeRef.current) return
        const localPoint = planeRef.current.worldToLocal(e.point.clone())
        const transformedPoint = transformPoint(localPoint, snapEnabled, snapValue)
        onCanvasClick(transformedPoint, snapEnabled, snapValue)
      }}
    >
      <planeGeometry args={[20, 20]} />
      <meshBasicMaterial transparent opacity={0.1} color="lightblue" />
      <CursorIndicator ref={cursorRef} />
    </mesh>
  )
}
export const CursorIndicator = ({ color = "orange", size = 0.1, ...props }) => {
  const ref = useRef()
  useFrame(() => {
    if (ref.current) {
      ref.current.rotation.x += 0.01
      ref.current.rotation.y += 0.01
    }
  })
  return (
    <mesh ref={ref} raycast={() => null} visible={false} {...props}>
      <sphereGeometry args={[size]} />
      <meshBasicMaterial color={color} />
    </mesh>
  )
}
export const GridHelper = memo(function GridHelper({ snapValue, visible }) {
  if (!visible) return null
  
  const lines = []
  const size = 20
  const divisions = size / snapValue
  
  for (let i = -divisions; i <= divisions; i++) {
    const pos = i * snapValue
    lines.push(
      <Line 
        key={`h${i}`}
        points={[[-size/2, pos, 0], [size/2, pos, 0]]}
        color="gray"
        lineWidth={0.5}
      />,
      <Line 
        key={`v${i}`}
        points={[[pos, -size/2, 0], [pos, size/2, 0]]}
        color="gray"
        lineWidth={0.5}
      />
    )
  }
  
  return <group>{lines}</group>
})
Refactored modular architecture
The Refactored Architecture
Our new system uses several design patterns to create a clean, extensible architecture:
1. Tool Strategy Pattern
Instead of hardcoded conditionals, we use the Strategy Pattern with a plugin-based tool system:
// Abstract base class defines the interface
export class BaseTool {
  constructor(name) {
    this.name = name
    this.isDrawing = false
    this.state = null
  }
  // Standardized interface all tools must implement
  onCanvasClick(point, snapEnabled, snapValue) {
    const snappedPoint = GeometryUtils.snapToGrid(point, snapEnabled, snapValue)
    
    if (!this.isDrawing) {
      return this.startDrawing(snappedPoint)
    } else {
      return this.continueDrawing(snappedPoint)
    }
  }
  // Abstract methods - must be implemented by subclasses
  startDrawing(point) {
    throw new Error('startDrawing must be implemented by subclass')
  }
  continueDrawing(point) {
    throw new Error('continueDrawing must be implemented by subclass')
  }
}
Each tool becomes a focused class that implements this interface:
export class LineTool extends BaseTool {
  constructor() {
    super('line')
  }
  startDrawing(point) {
    this.isDrawing = true
    this.state = { start: point, end: point }
    return { action: 'startDrawing', state: this.state }
  }
  continueDrawing(point) {
    const object = {
      type: 'line',
      id: GeometryUtils.generateId(),
      start: this.state.start,
      end: point
    }
    
    this.reset()
    return { action: 'complete', object }
  }
}
2. Tool Manager (Registry Pattern)
The ToolManager acts as a registry and delegates operations to the active tool:
export class ToolManager {
  constructor() {
    this.tools = new Map()
    this.activeTool = null
    
    // Register default tools
    this.registerTool('line', new LineTool())
    this.registerTool('shape', new ShapeTool())
    this.registerTool('rectangle', new RectangleTool())
  }
  handleCanvasClick(point, snapEnabled, snapValue) {
    if (!this.activeTool) return null
    return this.activeTool.onCanvasClick(point, snapEnabled, snapValue)
  }
  setActiveTool(toolName) {
    this.activeTool = this.tools.get(toolName)
  }
}
Now our main component becomes much simpler:
// Instead of giant conditional blocks:
const result = toolManager.handleCanvasClick(point, snapEnabled, snapValue)
// The tool manager delegates to the appropriate tool automatically
3. Custom Hooks for State Management
We extract state logic into focused custom hooks:
export function useDrawingState() {
  const [drawnObjects, setDrawnObjects] = useState([])
  const [toolManager] = useState(() => new ToolManager())
  
  const handleCanvasClick = useCallback((point, snapEnabled, snapValue) => {
    const result = toolManager.handleCanvasClick(point, snapEnabled, snapValue)
    
    // Handle result based on action type
    switch (result?.action) {
      case 'complete':
        setDrawnObjects(prev => [...prev, result.object])
        break
      case 'startDrawing':
        setIsDrawing(true)
        break
    }
  }, [toolManager])
  return {
    drawnObjects,
    handleCanvasClick,
    toolManager,
    // ... other state and actions
  }
}
4. Reusable Rendering Components
Common functionality is extracted into reusable components:
// Length display component
export function LengthDisplay({ start, end, color = "black", visible = true }) {
  if (!visible || !start || !end) return null
  
  const length = GeometryUtils.calculateDistance(start, end)
  const midpoint = GeometryUtils.calculateMidpoint(start, end)
  
  return (
    <Text
      position={[midpoint.x, midpoint.y, 0.1]}
      fontSize={0.2}
      color={color}
      anchorX="center"
      anchorY="middle"
    >
      {length.toFixed(2)}
    </Text>
  )
}
// Edge lines component
export function EdgeLines({ segments, color = "black", lineWidth = 1 }) {
  return (
    <>
      {segments.map((segment, i) => (
        <Line 
          key={i}
          points={[
            [segment.start.x, segment.start.y, 0.01], 
            [segment.end.x, segment.end.y, 0.01]
          ]}
          color={color}
          lineWidth={lineWidth}
        />
      ))}
    </>
  )
}
5. Utility Library
Common calculations are centralized in a utility library:
export const GeometryUtils = {
  calculateDistance: (p1, p2) => {
    return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2))
  },
  calculateMidpoint: (p1, p2) => ({
    x: (p1.x + p2.x) / 2,
    y: (p1.y + p2.y) / 2,
    z: 0
  }),
  snapToGrid: (point, snapEnabled, snapValue) => {
    if (!snapEnabled) return point
    return {
      x: Math.round(point.x / snapValue) * snapValue,
      y: Math.round(point.y / snapValue) * snapValue,
      z: 0
    }
  }
}
Benefits of the New Architecture
1. Extensibility
Adding a new tool is now trivial:
class CircleTool extends BaseTool {
  startDrawing(point) {
    this.isDrawing = true
    this.state = { center: point, radius: 0 }
    return { action: 'startDrawing', state: this.state }
  }
  continueDrawing(point) {
    const radius = GeometryUtils.calculateDistance(this.state.center, point)
    const object = {
      type: 'circle',
      id: GeometryUtils.generateId(),
      center: this.state.center,
      radius
    }
    
    this.reset()
    return { action: 'complete', object }
  }
}
// Register the new tool
toolManager.registerTool('circle', new CircleTool())
No existing code needs to be modified!
2. Maintainability
Each tool is self-contained with clear responsibilities:
- Tool classes handle their own state and logic
- Rendering components focus only on visualization
- Hooks manage state transitions
- Utilities provide pure functions
3. Testability
Individual components can be tested in isolation:
// Test a tool in isolation
const lineTool = new LineTool()
const result = lineTool.startDrawing({ x: 0, y: 0 })
expect(result.action).toBe('startDrawing')
expect(result.state.start).toEqual({ x: 0, y: 0 })
4. Reusability
Components can be used across different applications:
- LengthDisplaycan show measurements anywhere
- EdgeLinescan outline any shape
- GeometryUtilsprovides universal geometry functions
Key Design Patterns Used
- Strategy Pattern: Tool classes with common interface
- Registry Pattern: ToolManager for tool registration/delegation
- Command Pattern: Tools return action objects describing what happened
- Composition: Complex functionality built from simple, focused components
- Dependency Injection: Tools receive dependencies (GeometryUtils) rather than creating them
Performance Considerations
The refactored architecture maintains excellent performance:
- Tool switching has minimal overhead (just swapping object references)
- Rendering uses the same optimized React Three Fiber components
- State updates are granular and focused
- Memory usage is efficient with tool instance reuse
Lessons Learned
1. Start with Working Code
We refactored a working application. This allowed us to:
- Understand the exact requirements
- Maintain the same functionality
- Compare performance before/after
2. Abstractions Should Solve Real Problems
Each abstraction addresses specific issues:
- BaseTool: Eliminates tool-specific conditionals
- ToolManager: Centralizes tool switching logic
- Custom hooks: Separate state concerns from UI
- Utility functions: Eliminate code duplication
3. Incremental Refactoring Works
We could refactor piece by piece:
- Extract utility functions first
- Create tool classes second
- Build reusable components third
- Integrate with hooks last
Next Steps
This architecture opens up many possibilities:
Advanced Tools
- Text tool for adding labels
- Dimension tool for automatic dimensioning
- Arc tool for curved segments
- Freehand tool for sketch-like drawing
Enhanced Features
- Undo/Redo system with command pattern
- Layer management for organizing drawings
- Import/Export to common formats (SVG, DXF)
- Keyboard shortcuts for tool switching
Performance Optimizations
- Spatial indexing for efficient object selection
- Virtualization for large drawings
- WebWorkers for complex calculations
Conclusion
Refactoring our drawing tools from a monolithic structure to a modular architecture demonstrates the power of good software design. The new system is:
- Easier to extend (new tools in minutes, not hours)
- Simpler to maintain (focused, single-responsibility components)
- More testable (isolated, pure functions and classes)
- Highly reusable (components work across applications)
Most importantly, it maintains the same user experience while dramatically improving the developer experience.
The key insight is that abstractions should solve real problems. Each pattern we applied addressed specific pain points in the original code, making the refactored version not just cleaner, but genuinely easier to work with.
Whether you're building drawing tools, game engines, or any complex interactive application, these patterns will help you create systems that can grow and evolve with your requirements.