Introduction
Have you ever tried building a web application in React to create and edit various shapes, such as rectangles or triangles? The ability to draw and edit shapes is particularly useful when creating design tools and data visualizations. At first, it might seem tricky, but with a proper setup, it can become very easy.

React Hooks are functions that allow React components to manage internal state, handle side effects, and respond to user interactions without the need for class-based syntax. In this post, we’ll primarily use the following hooks:
- useState for local state management,
- useRef for mutable references that don’t cause re-renders,
- useCallback for memoizing functions to avoid unnecessary re-renders,
- useBoundStore – custom Zustand hook for managing global shared state.
Zustand is a powerful library that keeps the app’s state centralized and easily shareable across components, while integrating seamlessly with React Hooks.
As with any interactive application, adding more interactivity and scale leads to real-world UI challenges, such as performance drops, stale state bugs, and unnecessary re-renders.
In this article, we’ll go through a basic process for shape drawing with three implementations of an interactive shape editor built in React. Each version builds on the previous. We will start with a basic version, focusing on local shapes with useState, continue with a global state management version with Zustand, and finish with a finely-tuned hybrid approach that focuses on improving performance while still using Zustand.
By the end, we’ll have built an interactive shape editor that allows users to draw, select, move, and resize shapes, while keeping an eye on possible optimizations.
Project Overview
Before diving into the code, we will briefly review the project structure. Luckily, it is more or less the same for all three implementations of the shape editor.
The project consists of the following components:
- Rectangle.tsx, Triangle.tsx – basic shapes
- Toolbar.tsx – top bar which enables us to choose whether we want draw a rectangle, triangle, or just select the shapes on the screen
- ShapeEditor.tsx – main container that holds both the Toolbar and the shapes themselves
- (Zustand) shapeStore.tsx – global state for the two Zustand versions
Since all three versions can be swapped out, App.tsx only needs to change which ShapeEditor it imports.
src/
├── components/
│ ├── Toolbar.tsx # Toolbar for selecting tools
│ ├── ShapeEditor.tsx # Main container holding Toolbar and shapes
│ ├── shapes/
│ │ ├── Rectangle.tsx
│ │ ├── Triangle.tsx
│ └── drawingComponents/
│ ├── DrawRectangle.tsx
│ └── DrawTriangle.tsx
├── store/ # Zustand global state for shape data (used in Zustand app versions)
│ └── shapeStore.tsx
├── types.tx
├── main.tsx
└── App.tsx # Entry point that imports ShapeEditor variants
Project structure
Basic Local State – Drawing Shapes with useState
We will start with the simplest method. All shapes are stored in ShapeEditor using useState. We then create the addShape, updateShape, and deleteShape functions and pass them all to the underlying components that handle shape drawing.
import React, { useState, useRef } from 'react';
import Toolbar, { type Tool } from './Toolbar';
import DrawRectangle from './drawingComponents/DrawRectangle';
import DrawTriangle from './drawingComponents/DrawTriangle';
import type { Shape } from '../../types';
const toolToShapeType: Record<Tool, Shape['type'] | null> = {
select: null,
'draw-rectangle': 'rectangle',
'draw-triangle': 'triangle',
};
const ShapeEditor: React.FC = () => {
const [shapes, setShapes] = useState<Shape[]>([]);
const [tool, setTool] = useState<Tool>('select');
const [selectedId, setSelectedId] = useState<string | null>(null);
const shapeClickedRef = useRef(false);
const toolbarClickedRef = useRef(false);
const addShape = (type: Shape['type'], x: number, y: number) => {
const newShape: Shape = {
id: crypto.randomUUID(),
x,
y,
width: 100,
height: 100,
rotation: 0,
type,
};
setShapes((prev) => [...prev, newShape]);
setSelectedId(newShape.id);
};
const onShapeMouseDown = () => {
shapeClickedRef.current = true;
};
const onToolbarMouseDown = () => {
toolbarClickedRef.current = true;
};
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (shapeClickedRef.current || toolbarClickedRef.current) {
shapeClickedRef.current = false;
toolbarClickedRef.current = false;
return;
}
const shapeType = toolToShapeType[tool];
if (shapeType) {
addShape(shapeType, e.clientX, e.clientY);
} else {
setSelectedId(null);
}
};
const updateShape = (newShape: Shape) => {
setShapes((prev) => prev.map((s) => (s.id === newShape.id ? newShape : s)));
};
const deleteShape = (id: string) => {
setShapes((prev) => prev.filter((s) => s.id !== id));
};
return (
<div
onClick={handleClick}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
overflow: 'hidden',
}}
>
<Toolbar tool={tool} setTool={setTool} onMouseDown={onToolbarMouseDown} />
{shapes.map((shape) => {
const isSelected = shape.id === selectedId;
const commonProps = {
shape,
updateShape,
isSelected,
onSelect: () => setSelectedId(shape.id),
onShapeMouseDown,
deleteShape,
};
if (shape.type === 'rectangle') {
return <DrawRectangle key={shape.id} {...commonProps} />;
} else if (shape.type === 'triangle') {
return <DrawTriangle key={shape.id} {...commonProps} />;
} else {
return null;
}
})}
</div>
);
};
export default ShapeEditor;
Local state – ShapeEditor
Inside of DrawRectangle, we call the passed updateShape() in handleMouseMove(), effectively updating the shape with every mouse movement.
Since the shapes array that we are changing is stored as a state in the top-level component (ShapeEditor), we are effectively re-rendering the main component and all its children frequently, which can become quite expensive in terms of performance.
import React, { useRef } from 'react';
import type { Shape } from '../../../types';
import Rectangle from '../shapes/Rectangle';
interface Props {
shape: Shape;
isSelected: boolean;
updateShape: (shape: Shape) => void;
onSelect: () => void;
onShapeMouseDown: () => void;
deleteShape: (id: string) => void;
}
const DrawRectangle: React.FC<Props> = ({
shape,
isSelected,
updateShape,
deleteShape,
onSelect,
onShapeMouseDown,
}) => {
const offset = useRef({ x: 0, y: 0 });
const handleDragStart = (e: React.MouseEvent) => {
e.stopPropagation();
offset.current = {
x: e.clientX - shape.x,
y: e.clientY - shape.y,
};
const handleMouseMove = (e: MouseEvent) => {
updateShape({
...shape,
x: e.clientX - offset.current.x,
y: e.clientY - offset.current.y,
});
};
const handleMouseUp = () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
const handleResizeStart = (
e: React.MouseEvent,
corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right',
) => {
e.stopPropagation();
e.preventDefault();
onSelect();
const startX = e.clientX;
const startY = e.clientY;
const { width, height, x, y } = shape;
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newWidth = width;
let newHeight = height;
let newX = x;
let newY = y;
if (corner === 'bottom-right') {
newWidth = width + dx;
newHeight = height + dy;
} else if (corner === 'bottom-left') {
newWidth = width - dx;
newHeight = height + dy;
newX = x + dx;
} else if (corner === 'top-right') {
newWidth = width + dx;
newHeight = height - dy;
newY = y + dy;
} else if (corner === 'top-left') {
newWidth = width - dx;
newHeight = height - dy;
newX = x + dx;
newY = y + dy;
}
if (Math.abs(newWidth) < 10) newWidth = newWidth < 0 ? -10 : 10;
if (Math.abs(newHeight) < 10) newHeight = newHeight < 0 ? -10 : 10;
updateShape({
...shape,
x: newX,
y: newY,
width: newWidth,
height: newHeight,
});
};
const handleMouseUp = () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
const handleMouseDown = (e: React.MouseEvent) => {
e.stopPropagation();
onShapeMouseDown();
onSelect();
handleDragStart(e);
};
return (
<div
onMouseDown={handleMouseDown}
style={{
position: 'absolute',
left: shape.x,
top: shape.y,
width: Math.abs(shape.width),
height: Math.abs(shape.height),
transform: `rotate(${shape.rotation}deg) scaleX(${shape.width < 0 ? -1 : 1}) scaleY(${
shape.height < 0 ? -1 : 1
})`,
transformOrigin: 'top left',
userSelect: 'none',
}}
>
<Rectangle />
{isSelected &&
(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).map((corner) => {
const styles: React.CSSProperties = {
position: 'absolute',
width: 10,
height: 10,
backgroundColor: 'white',
borderRadius: 5,
border: '1px solid black',
cursor: `${corner}-resize`,
};
if (corner.includes('top')) styles.top = -5;
if (corner.includes('bottom')) styles.bottom = -5;
if (corner.includes('left')) styles.left = -5;
if (corner.includes('right')) styles.right = -5;
return (
<div
key={corner}
onMouseDown={(e) => {
onShapeMouseDown();
handleResizeStart(e, corner);
}}
style={styles}
/>
);
})}
{isSelected && (
<button
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
deleteShape(shape.id);
}}
style={{
position: 'absolute',
top: -25,
right: -25,
width: 20,
height: 20,
backgroundColor: 'red',
color: 'white',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
fontSize: 12,
lineHeight: '20px',
textAlign: 'center',
zIndex: 20,
}}
>
×
</button>
)}
</div>
);
};
export default DrawRectangle;
Local state – DrawRectangle (shape movement)
Another issue is that once the ShapeEditor is unmounted, we lose all the drawn shapes, since they are stored locally. We will address this issue by introducing a global store using Zustand.
Shared Global State with Zustand
To address the issue of disappearing shapes on component unmount, we introduce Zustand, which serves as a shared global state. While Zustand offers two modes of operation, in this blog we will focus on the subscription mode, which effectively behaves like a regular React state. What that means is that all the states we take from the store cause re-renders whenever they are changed, while still providing cross-component persistence.
The first step is to create a simple store that holds the shapes, the id of the selected shape and functions for interacting with them. This eliminates the need to declare addShape, updateShape, and deleteShape within ShapeEditor.tsx, unlike before.
import { create } from 'zustand';
import type { Shape } from '../../types';
interface ShapeState {
shapes: Shape[];
selectedId: string | null;
addShape: (shape: Shape) => void;
updateShape: (shape: Shape) => void;
deleteShape: (id: string) => void;
selectShape: (id: string | null) => void;
}
export const useBoundStore = create<ShapeState>((set) => ({
shapes: [],
selectedId: null,
addShape: (shape) =>
set((state) => ({
shapes: [...state.shapes, shape],
selectedId: shape.id,
})),
updateShape: (shape) =>
set((state) => ({
shapes: state.shapes.map((s) => (s.id === shape.id ? shape : s)),
})),
deleteShape: (id) =>
set((state) => ({
shapes: state.shapes.filter((s) => s.id !== id),
selectedId: state.selectedId === id ? null : state.selectedId,
})),
selectShape: (id) => set({ selectedId: id }),
}));
Zustand store
Once we create the store, we can proceed to adjust the rest. Luckily, most of the code remains unchanged. The only file we need to change is ShapeEditor.tsx.
Now, we need to swap out the [shapes, setShapes] state and replace the local add, update, delete, and select functions with the ones provided by the store:
const ShapeEditor: React.FC = () => {
const {
shapes,
selectedId,
addShape,
updateShape,
deleteShape,
selectShape
} = useBoundStore();
Accessing states – Subscription mode
One important detail is the use of useBoundStore(), which enables the “subscription” mode for Zustand. We will avoid using useBoundStore.getState(), as that would change the behavior of the application, and require a slightly different approach, since .getState() reads the state without subscribing to updates, so components won’t re-render when changes happen.
Now that we have added Zustand’s store, we can freely use the shapes anywhere within the application.The only issue is that we are still re-rendering ShapeEditor and all its children very frequently. To combat this, we have to move the updateShape() call from the onMouseMove handler to onMouseUp. We only want to commit the change once we finish moving or resizing the shape.
Efficient Shape Interaction Using Zustand and useCallback
Finally, we introduce the third version, which gives us the best of both worlds:
- Shared global store,
- Optimized shape management.
Optimized shape management is achieved by reducing the frequency of updates to the ShapeEditor and all its children. As mentioned before, we do this by moving updateShape() to onMouseUp. This introduces another, luckily, easily solvable issue.
Initially, inside of DrawRectangle, we used useRef to keep the value of the current mouse / shape position. If we try to do that now, we will not see any changes on the screen until we let go of the mouse button, as refs do not cause re-renders, and the state is only updated in the onMouseUp handler.
Now, if we introduce a local state within DrawRectangle, we can easily resolve that issue; however, we are faced with yet another problem: the event handlers use outdated / stale values from the local state. The solution is to introduce useCallback, which recreates the handlers whenever the values in its dependency array change.
After all these changes, comparing the performance of the first two implementation versions (local state and Zustand) with the current implementation shows a significant improvement.
In the first two versions, ShapeEditor renders three other components: Toolbar, DrawRectangle, and DrawTriangle. Every time we move the mouse, all four components re-render. If the handleMouseMove function fires 1000 times, this results in 4000 re-renders. On the other hand, the current version only re-renders the DrawRectangle component during handleMouseMove calls and re-renders the rest only once the mouse is released, resulting in a total of 1004 re-renders.
Naturally, if the number of child components in ShapeEditor increases, so does the performance gap.
One trade-off that we need to make for this approach is the temporary desynchronization of local and global state. The global state only updates on mouse up, meaning that other components will not notice the change in the shape’s location or scale until the action completes.
import React, { useState, useCallback } from 'react';
import type { Shape } from '../../../types';
import Rectangle from '../shapes/Rectangle';
interface Props {
shape: Shape;
isSelected: boolean;
updateShape: (shape: Shape) => void;
onSelect: () => void;
onShapeMouseDown: () => void;
deleteShape: (id: string) => void;
}
const MIN_SIZE = 10;
const DrawRectangle: React.FC<Props> = ({
shape,
isSelected,
updateShape,
deleteShape,
onSelect,
onShapeMouseDown,
}) => {
const [position, setPosition] = useState({ x: shape.x, y: shape.y });
const [size, setSize] = useState({ width: shape.width, height: shape.height });
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onShapeMouseDown();
onSelect();
const offsetX = e.clientX - position.x;
const offsetY = e.clientY - position.y;
const handleMouseMove = (e: MouseEvent) => {
setPosition({ x: e.clientX - offsetX, y: e.clientY - offsetY });
};
const handleMouseUp = () => {
updateShape({
...shape,
x: position.x,
y: position.y,
width: size.width,
height: size.height,
});
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
},
[position, size, shape, updateShape, onSelect, onShapeMouseDown],
);
const handleResizeStart = useCallback(
(e: React.MouseEvent, corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right') => {
e.stopPropagation();
e.preventDefault();
onSelect();
onShapeMouseDown();
const startX = e.clientX;
const startY = e.clientY;
const startWidth = size.width;
const startHeight = size.height;
const startXPos = position.x;
const startYPos = position.y;
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newWidth = startWidth;
let newHeight = startHeight;
let newX = startXPos;
let newY = startYPos;
if (corner === 'bottom-right') {
newWidth = startWidth + dx;
newHeight = startHeight + dy;
} else if (corner === 'bottom-left') {
newWidth = startWidth - dx;
newHeight = startHeight + dy;
newX = startXPos + dx;
} else if (corner === 'top-right') {
newWidth = startWidth + dx;
newHeight = startHeight - dy;
newY = startYPos + dy;
} else if (corner === 'top-left') {
newWidth = startWidth - dx;
newHeight = startHeight - dy;
newX = startXPos + dx;
newY = startYPos + dy;
}
if (Math.abs(newWidth) < MIN_SIZE) newWidth = newWidth < 0 ? -MIN_SIZE : MIN_SIZE;
if (Math.abs(newHeight) < MIN_SIZE) newHeight = newHeight < 0 ? -MIN_SIZE : MIN_SIZE;
setPosition({ x: newX, y: newY });
setSize({ width: newWidth, height: newHeight });
};
const handleMouseUp = () => {
updateShape({
...shape,
x: position.x,
y: position.y,
width: size.width,
height: size.height,
});
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
},
[position, size, shape, updateShape, onSelect, onShapeMouseDown],
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
handleDragStart(e);
},
[handleDragStart],
);
return (
<div
onMouseDown={handleMouseDown}
style={{
position: 'absolute',
left: position.x,
top: position.y,
width: Math.abs(size.width),
height: Math.abs(size.height),
transform: `rotate(${shape.rotation}deg) scaleX(${size.width < 0 ? -1 : 1}) scaleY(${
size.height < 0 ? -1 : 1
})`,
transformOrigin: 'top left',
userSelect: 'none',
}}
>
<Rectangle />
{isSelected &&
(['top-left', 'top-right', 'bottom-left', 'bottom-right'] as const).map((corner) => {
const styles: React.CSSProperties = {
position: 'absolute',
width: 10,
height: 10,
backgroundColor: 'white',
borderRadius: 5,
border: '1px solid black',
cursor: `${corner}-resize`,
};
if (corner.includes('top')) styles.top = -5;
if (corner.includes('bottom')) styles.bottom = -5;
if (corner.includes('left')) styles.left = -5;
if (corner.includes('right')) styles.right = -5;
return (
<div key={corner} onMouseDown={(e) => handleResizeStart(e, corner)} style={styles} />
);
})}
{isSelected && (
<button
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
deleteShape(shape.id);
}}
style={{
position: 'absolute',
top: -25,
right: -25,
width: 20,
height: 20,
backgroundColor: 'red',
color: 'white',
border: 'none',
borderRadius: '50%',
cursor: 'pointer',
fontSize: 12,
lineHeight: '20px',
textAlign: 'center',
zIndex: 20,
}}
>
×
</button>
)}
</div>
);
};
export default DrawRectangle;
Optimized DrawRectangle approach
Conclusion
Interactivity is one of React’s strongest points, but it is also where significant performance bottlenecks can appear if not managed carefully.
We started with a naive approach, advanced to a model that introduces a global store, and finally optimized its performance by controlling the components that we want to re-render.
While this is just a barebones example, it can be scaled up immensely. A few simple features were implemented, such as moving and scaling shapes, showing that many other improvements are possible.
“Building Interactive Shapes with React Hooks” Tech Bite was brought to you by Dženis Kajević, Junior Software Engineer at Atlantbh.
Tech Bites are tips, tricks, snippets or explanations about various programming technologies and paradigms, which can help engineers with their everyday job.