Build a tiny pixel-art editor: a color palette and a grid canvas you paint by clicking — or dragging — cells. The canvas is just an array of colors (one per pixel), painting writes the selected color into a cell, and drag-to-paint is the same write driven by mouse-enter while the button is held.
// A self-contained component. No props.
function App(): JSX.Element;
A row of color swatches and a grid of pixels.
pick red, click pixel 12 → cells[12] = red
pick blue, drag across a row → every pixel the pointer enters turns blue
the active swatch is outlined; clicking a pixel with no color selected
still paints the current (default first) color.
cells[i] is the color of pixel i; render each pixel's background from it.current; painting writes current into a cell.painting flag set on mousedown, cleared on mouseup; paint on mouseenter while it's true.The canvas is an array of colors, one per pixel. A palette selects the current color; painting writes current into a cell. Drag-to-paint is the same write, triggered by mouseenter while the mouse button is held.
A pixel editor sounds visual, but the model is plain data: a flat array where cells[i] is the color of pixel i. Clicking a pixel paints it the selected color; that's an immutable array update. The only extra piece is dragging — you want to paint every pixel the pointer crosses while the button is down, which means remembering "am I painting right now?" and painting on mouseenter, not just click.
Three pieces of state: cells (array of color strings), current (the selected palette color), and painting (a boolean, true while the mouse is down on the canvas). A swatch click sets current. paint(i) writes current into cells[i] immutably. On a pixel: mousedown sets painting = true and paints it; mouseenter paints only if painting; a global mouseup sets painting = false. So a single click paints one pixel, and a drag paints a streak.
A first attempt mutates the array in place:
function paint(i) {
cells[i] = current; // same array reference → React won't re-render
}
Mutating cells[i] directly changes the contents but not the array's identity, so React sees no change and the pixel doesn't repaint on screen. You must produce a new array — copy, set index i, and setCells. (And for drag, a click-only handler misses the pixels you sweep over; you need mouseenter gated by a painting flag.)
import { useState, useEffect } from 'react';
import './styles.css';
const PALETTE = ['#fbbf24', '#38bdf8', '#f87171', '#4ade80', '#a78bfa', '#1e293b'];
const SIZE = 100; // 10 x 10
const EMPTY = '#1e293b';
export default function App() {
const [cells, setCells] = useState<string[]>(() => Array(SIZE).fill(EMPTY));
const [current, setCurrent] = useState(PALETTE[0]);
const [painting, setPainting] = useState(false);
// Releasing anywhere ends the stroke.
useEffect(() => {
const up = () => setPainting(false);
window.addEventListener('mouseup', up);
return () => window.removeEventListener('mouseup', up);
}, []);
function paint(i: number) {
setCells((prev) => {
if (prev[i] === current) return prev; // no-op: avoid needless re-render
const next = prev.slice();
next[i] = current;
return next;
});
}
function start(i: number) {
setPainting(true);
paint(i);
}
return (
<main className="container">
<h1>Pixel Art</h1>
<div className="palette">
{PALETTE.map((c) => (
<button
key={c}
className={c === current ? 'swatch active' : 'swatch'}
style={{ background: c }}
onClick={() => setCurrent(c)}
/>
))}
</div>
<div className="canvas">
{cells.map((color, i) => (
<div
key={i}
className="pixel"
style={{ background: color }}
onMouseDown={() => start(i)}
onMouseEnter={() => painting && paint(i)}
/>
))}
</div>
</main>
);
}
cells starts as a flat array of the empty color. paint(i) updates immutably — copy, set index i, return the new array — and short-circuits when the color already matches, so dragging across same-colored pixels doesn't churn renders. start (mousedown) flips painting on and paints the first pixel; mouseenter paints subsequent pixels only while painting. The global mouseup listener ends the stroke even if you release off the canvas. Each pixel's background is read straight from cells[i], and the active swatch gets the active outline from c === current.
cells all empty, current = PALETTE[0] (amber).
setCurrent('#38bdf8'); that swatch gets the active outline.start(12): painting = true, paint(12) copies cells, sets index 12 to blue → that pixel turns blue.mouseenter sees painting === true and calls paint, turning them blue. Re-entering an already-blue pixel hits the prev[i] === current no-op.painting = false. Now moving over pixels does nothing (the mouseenter guard fails).start(30) paints a single pixel and immediately the mouseup ends it — a plain click paints one cell.cells[i]. Same reference → no re-render. Fix: slice() then set the index.click-only painting. Misses pixels you drag across. Fix: paint on mouseenter gated by painting.mouseup on the canvas only. Release off-canvas leaves painting stuck true. Fix: a window mouseup listener.user-select: none on the canvas.prev[i] === current no-op guard.cells to all-empty.touchmove + elementFromPoint to the same paint.cells to a data URL or JSON to save the artwork.A reducer owns immutable canvas updates while ordinary state tracks the active color and pointer lifecycle. The rendered grid is unchanged.
import { useEffect, useReducer, useState } from 'react';
import './styles.css';
const PALETTE = ['#fbbf24', '#38bdf8', '#f87171', '#4ade80', '#a78bfa', '#1e293b'];
const EMPTY = '#1e293b';
type Paint = { index: number; color: string };
function reducer(cells: string[], action: Paint): string[] {
if (cells[action.index] === action.color) return cells;
return cells.map((color, index) => index === action.index ? action.color : color);
}
export default function App() {
const [cells, paint] = useReducer(reducer, Array(100).fill(EMPTY));
const [current, setCurrent] = useState(PALETTE[0]);
const [drawing, setDrawing] = useState(false);
useEffect(() => { const stop = () => setDrawing(false); window.addEventListener('mouseup', stop); return () => window.removeEventListener('mouseup', stop); }, []);
const start = (index: number) => { setDrawing(true); paint({ index, color: current }); };
return <main className="container">
<h1>Pixel Art</h1>
<div className="palette">{PALETTE.map((color) => <button key={color} className={color === current ? 'swatch active' : 'swatch'} style={{ background: color }} onClick={() => setCurrent(color)} />)}</div>
<div className="canvas">{cells.map((color, index) => <div key={index} className="pixel" style={{ background: color }} onMouseDown={() => start(index)} onMouseEnter={() => { if (drawing) paint({ index, color: current }); }} />)}</div>
</main>;
}A custom hook exposes the canvas model and focused commands, keeping pointer state details out of the visual component.
import { useEffect, useState } from 'react';
import './styles.css';
const PALETTE = ['#fbbf24', '#38bdf8', '#f87171', '#4ade80', '#a78bfa', '#1e293b'];
function usePixelEditor() {
const [cells, setCells] = useState<string[]>(() => Array(100).fill('#1e293b'));
const [current, select] = useState(PALETTE[0]);
const [drawing, setDrawing] = useState(false);
const paint = (index: number) => setCells((values) => values[index] === current ? values : values.map((value, position) => position === index ? current : value));
const start = (index: number) => { setDrawing(true); paint(index); };
const enter = (index: number) => { if (drawing) paint(index); };
useEffect(() => { const release = () => setDrawing(false); document.addEventListener('mouseup', release); return () => document.removeEventListener('mouseup', release); }, []);
return { cells, current, select, start, enter };
}
export default function App() {
const editor = usePixelEditor();
return <main className="container">
<h1>Pixel Art</h1>
<div className="palette">{PALETTE.map((color) => <button key={color} className={color === editor.current ? 'swatch active' : 'swatch'} style={{ background: color }} onClick={() => editor.select(color)} />)}</div>
<div className="canvas">{editor.cells.map((color, index) => <div key={index} className="pixel" style={{ background: color }} onMouseDown={() => editor.start(index)} onMouseEnter={() => editor.enter(index)} />)}</div>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a tiny pixel-art editor: a color palette and a grid canvas you paint by clicking — or dragging — cells. The canvas is just an array of colors (one per pixel), painting writes the selected color into a cell, and drag-to-paint is the same write driven by mouse-enter while the button is held.
// A self-contained component. No props.
function App(): JSX.Element;
A row of color swatches and a grid of pixels.
pick red, click pixel 12 → cells[12] = red
pick blue, drag across a row → every pixel the pointer enters turns blue
the active swatch is outlined; clicking a pixel with no color selected
still paints the current (default first) color.
cells[i] is the color of pixel i; render each pixel's background from it.current; painting writes current into a cell.painting flag set on mousedown, cleared on mouseup; paint on mouseenter while it's true.The canvas is an array of colors, one per pixel. A palette selects the current color; painting writes current into a cell. Drag-to-paint is the same write, triggered by mouseenter while the mouse button is held.
A pixel editor sounds visual, but the model is plain data: a flat array where cells[i] is the color of pixel i. Clicking a pixel paints it the selected color; that's an immutable array update. The only extra piece is dragging — you want to paint every pixel the pointer crosses while the button is down, which means remembering "am I painting right now?" and painting on mouseenter, not just click.
Three pieces of state: cells (array of color strings), current (the selected palette color), and painting (a boolean, true while the mouse is down on the canvas). A swatch click sets current. paint(i) writes current into cells[i] immutably. On a pixel: mousedown sets painting = true and paints it; mouseenter paints only if painting; a global mouseup sets painting = false. So a single click paints one pixel, and a drag paints a streak.
A first attempt mutates the array in place:
function paint(i) {
cells[i] = current; // same array reference → React won't re-render
}
Mutating cells[i] directly changes the contents but not the array's identity, so React sees no change and the pixel doesn't repaint on screen. You must produce a new array — copy, set index i, and setCells. (And for drag, a click-only handler misses the pixels you sweep over; you need mouseenter gated by a painting flag.)
import { useState, useEffect } from 'react';
import './styles.css';
const PALETTE = ['#fbbf24', '#38bdf8', '#f87171', '#4ade80', '#a78bfa', '#1e293b'];
const SIZE = 100; // 10 x 10
const EMPTY = '#1e293b';
export default function App() {
const [cells, setCells] = useState<string[]>(() => Array(SIZE).fill(EMPTY));
const [current, setCurrent] = useState(PALETTE[0]);
const [painting, setPainting] = useState(false);
// Releasing anywhere ends the stroke.
useEffect(() => {
const up = () => setPainting(false);
window.addEventListener('mouseup', up);
return () => window.removeEventListener('mouseup', up);
}, []);
function paint(i: number) {
setCells((prev) => {
if (prev[i] === current) return prev; // no-op: avoid needless re-render
const next = prev.slice();
next[i] = current;
return next;
});
}
function start(i: number) {
setPainting(true);
paint(i);
}
return (
<main className="container">
<h1>Pixel Art</h1>
<div className="palette">
{PALETTE.map((c) => (
<button
key={c}
className={c === current ? 'swatch active' : 'swatch'}
style={{ background: c }}
onClick={() => setCurrent(c)}
/>
))}
</div>
<div className="canvas">
{cells.map((color, i) => (
<div
key={i}
className="pixel"
style={{ background: color }}
onMouseDown={() => start(i)}
onMouseEnter={() => painting && paint(i)}
/>
))}
</div>
</main>
);
}
cells starts as a flat array of the empty color. paint(i) updates immutably — copy, set index i, return the new array — and short-circuits when the color already matches, so dragging across same-colored pixels doesn't churn renders. start (mousedown) flips painting on and paints the first pixel; mouseenter paints subsequent pixels only while painting. The global mouseup listener ends the stroke even if you release off the canvas. Each pixel's background is read straight from cells[i], and the active swatch gets the active outline from c === current.
cells all empty, current = PALETTE[0] (amber).
setCurrent('#38bdf8'); that swatch gets the active outline.start(12): painting = true, paint(12) copies cells, sets index 12 to blue → that pixel turns blue.mouseenter sees painting === true and calls paint, turning them blue. Re-entering an already-blue pixel hits the prev[i] === current no-op.painting = false. Now moving over pixels does nothing (the mouseenter guard fails).start(30) paints a single pixel and immediately the mouseup ends it — a plain click paints one cell.cells[i]. Same reference → no re-render. Fix: slice() then set the index.click-only painting. Misses pixels you drag across. Fix: paint on mouseenter gated by painting.mouseup on the canvas only. Release off-canvas leaves painting stuck true. Fix: a window mouseup listener.user-select: none on the canvas.prev[i] === current no-op guard.cells to all-empty.touchmove + elementFromPoint to the same paint.cells to a data URL or JSON to save the artwork.A reducer owns immutable canvas updates while ordinary state tracks the active color and pointer lifecycle. The rendered grid is unchanged.
import { useEffect, useReducer, useState } from 'react';
import './styles.css';
const PALETTE = ['#fbbf24', '#38bdf8', '#f87171', '#4ade80', '#a78bfa', '#1e293b'];
const EMPTY = '#1e293b';
type Paint = { index: number; color: string };
function reducer(cells: string[], action: Paint): string[] {
if (cells[action.index] === action.color) return cells;
return cells.map((color, index) => index === action.index ? action.color : color);
}
export default function App() {
const [cells, paint] = useReducer(reducer, Array(100).fill(EMPTY));
const [current, setCurrent] = useState(PALETTE[0]);
const [drawing, setDrawing] = useState(false);
useEffect(() => { const stop = () => setDrawing(false); window.addEventListener('mouseup', stop); return () => window.removeEventListener('mouseup', stop); }, []);
const start = (index: number) => { setDrawing(true); paint({ index, color: current }); };
return <main className="container">
<h1>Pixel Art</h1>
<div className="palette">{PALETTE.map((color) => <button key={color} className={color === current ? 'swatch active' : 'swatch'} style={{ background: color }} onClick={() => setCurrent(color)} />)}</div>
<div className="canvas">{cells.map((color, index) => <div key={index} className="pixel" style={{ background: color }} onMouseDown={() => start(index)} onMouseEnter={() => { if (drawing) paint({ index, color: current }); }} />)}</div>
</main>;
}A custom hook exposes the canvas model and focused commands, keeping pointer state details out of the visual component.
import { useEffect, useState } from 'react';
import './styles.css';
const PALETTE = ['#fbbf24', '#38bdf8', '#f87171', '#4ade80', '#a78bfa', '#1e293b'];
function usePixelEditor() {
const [cells, setCells] = useState<string[]>(() => Array(100).fill('#1e293b'));
const [current, select] = useState(PALETTE[0]);
const [drawing, setDrawing] = useState(false);
const paint = (index: number) => setCells((values) => values[index] === current ? values : values.map((value, position) => position === index ? current : value));
const start = (index: number) => { setDrawing(true); paint(index); };
const enter = (index: number) => { if (drawing) paint(index); };
useEffect(() => { const release = () => setDrawing(false); document.addEventListener('mouseup', release); return () => document.removeEventListener('mouseup', release); }, []);
return { cells, current, select, start, enter };
}
export default function App() {
const editor = usePixelEditor();
return <main className="container">
<h1>Pixel Art</h1>
<div className="palette">{PALETTE.map((color) => <button key={color} className={color === editor.current ? 'swatch active' : 'swatch'} style={{ background: color }} onClick={() => editor.select(color)} />)}</div>
<div className="canvas">{editor.cells.map((color, index) => <div key={index} className="pixel" style={{ background: color }} onMouseDown={() => editor.start(index)} onMouseEnter={() => editor.enter(index)} />)}</div>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.