Drawing an entire Sudoku board in one SkPicture (and why the declarative Canvas stuttered)
9 min readWritten by: Jonathan Reis on
The Sudoku app had two board renderers: native (React Native views) and declarative Skia (<Canvas> with 81 components). On modest devices, the Skia declarative reconciler became the frame time bottleneck. The fix was not to optimize the reconciler; it was to take it off the critical path with PictureRecorder.

The board in Daily Sudoku: Offline Puzzle renders on a <Canvas> from @shopify/react-native-skia. Each of the 81 cells is a handful of declarative nodes (<Rect>, <SkiaText>, <RoundedRect>). It works and looks good. On older devices, it stutters.
It does not stutter because drawing is slow. Skia draws fast. The problem is that, between tapping a cell and the ink hitting the screen, the React reconciler reconciles 81 components, each with props, each re-rendering when the selection changes. On a Redmi Note 7, that is tens of milliseconds per frame lost to JavaScript that draws nothing.
The question we asked was: can you draw the entire board without creating 81 React components? The answer is yes, and the path is PictureRecorder.
The problem: reconciler on the hot path
The current declarative Skia renderer does this:
<Canvas>
<SkiaCells cellSize={cellSize} cells={cells} colors={colors} />
<SkiaSelectionHighlights ... />
<SkiaValues ... />
<SkiaNotes ... />
<SkiaWrongBorders ... />
<SkiaAnimationLayer overlays={overlays} ... />
<SkiaGridLines ... />
</Canvas>
Each subcomponent maps 81 cells into Skia nodes. SkiaSelectionHighlights is the worst: when you select a cell, the entire array of 81 is re-mapped because selectedIndex changed and the filtering useMemo was invalidated. Skia itself is fast, but the overhead of reconciling 81 React nodes, with animated props and useDerivedValue for each animation overlay, adds up.
On a modern iPhone this is invisible. On a modest Android, each selection change reads as a micro-stutter. Animating a completed-unit wave (highlights by Manhattan distance across up to 27 cells) makes it worse.
The discovery: useDrawCallback is gone
The first idea was to use Skia’s useDrawCallback, the classic imperative API every 2022 tutorial shows. The problem: @shopify/react-native-skia 2.6.7 no longer has useDrawCallback. Or SkiaView. Those names exist in old versions and old tutorials, but the modern API moved to <Canvas> + declarative nodes as the primary layer.
Searching for useDrawCallback in the library’s types returns nothing. What exists is:
Canvas(React component) with arefof typeCanvasRef.CanvasRef.redraw()forces a repaint.Skia.PictureRecorder()returns an imperative recorder.recorder.beginRecording(bounds)returns anSkCanvaswhere you draw withdrawRect,drawLine,drawText,drawRRect— pure, no React.recorder.finishRecordingAsPicture()returns anSkPicture.<Picture picture={pictureRef.current} />renders the picture inside<Canvas>.
In other words: the imperative mode exists, but you reach it by recording an SkPicture and handing it to the declarative <Canvas>. You do not replace <Canvas> with a SkiaView; you replace the 81 React components with a single <Picture>.
The architecture: state outside the React tree
The imperative renderer keeps everything in refs:
const stateRef = useRef<BoardState>({
cells: [],
overlays: [],
mistakeHiddenIndexes: new Set(),
selectedIndex: null,
// ...
});
const [picture, setPicture] = useState<SkPicture | null>(null);
const canvasRef = useRef<CanvasRef | null>(null);
When props change (selection, board, notes, animationPresets), a useEffect re-records the picture:
const recordPicture = useCallback(() => {
const recorder = Skia.PictureRecorder();
const canvas = recorder.beginRecording(bounds);
drawBoard(canvas, stateRef.current, performance.now(), paints, fonts, colors);
setPicture(recorder.finishRecordingAsPicture());
}, [colors, fonts]);
React does not re-render 81 cells. It re-renders only the top-level component, which reads the picture state and renders <Picture picture={picture} />. The setPicture is what makes the picture snapshot reach the native view — a useRef mutation would not trigger the <Picture> re-render.
drawBoard is a function that takes an SkCanvas and draws everything in the right order: backgrounds, highlights, notes, values, wrong borders, animation overlays, grid lines. No JSX, no reconciler.
The imperative animation loop
Animations are where the real gain shows. In the declarative renderer, each animation overlay is a <SkiaAnimationRect> with useSharedValue + withSequence + withTiming. Each completed unit can create 27 simultaneous overlays, each with its own useEffect and useAnimatedStyle. Reanimated is good, but 27 simultaneous withSequence instances have cost.
In the imperative renderer, a single requestAnimationFrame loop re-records the picture every frame:
const animationLoop = useCallback(() => {
recordPicture();
const overlays = animationOverlaysRef.current;
if (overlays.length > 0) {
const elapsed = performance.now() - start;
const maxEnd = Math.max(...overlays.map((o) => o.delayMs + o.durationMs));
if (elapsed < maxEnd + 32) {
animationFrameRef.current = requestAnimationFrame(animationLoop);
} else {
animationOverlaysRef.current = [];
animationFrameRef.current = null;
}
} else {
animationFrameRef.current = null;
}
}, [recordPicture]);
drawBoard receives performance.now() and interpolates alphas with easeOutCubic. The mistake shake is a function of elapsed in ms. No useSharedValue, no withSequence, no useAnimatedStyle. One requestAnimationFrame for all active animations.
This is the part that matters on modest devices: the per-frame cost is constant, it does not scale with overlay count. 27 completed-unit overlays cost the same as 1, because you are redrawing the whole picture anyway.
The non-obvious detail: useRef does not trigger <Picture> re-render
Here is the trap that cost time and caused a misplaced-touch bug in production. The first version stored the SkPicture in pictureRef.current (useRef mutation) and rendered <Picture picture={pictureRef.current} />. The board stayed stale — drawn with the old picture. The touch selected the correct cell in the hitbox, but the visual highlight appeared on the cell from the old picture. It looked like the touch did not match the finger.
The problem: React does not observe ref mutations. The declarative <Picture> never re-rendered to pick up the new SkPicture. The fix was to swap pictureRef.current = nextPicture for setPicture(nextPicture) — useState triggers the re-render and the <Picture> receives the new picture every frame.
If you try canvasRef.current?.redraw() without swapping the picture via state, redraw() repaints the picture already in the <Picture>, not the new one in the ref. redraw() alone does not propagate a new SkPicture; it only repaints the existing one. The state is the channel.
PaintCache: do not create SkPaint per frame
Another detail: Skia.Paint() creates a native object. Doing that for 81 cells per frame at 60fps is 4,860 allocations per second. The fix is a simple cache:
class PaintCache {
private fillCache = new Map<string, SkPaint>();
private strokeCache = new Map<string, SkPaint>();
private textCache = new Map<string, SkPaint>();
fill(color: string, alpha = 1): SkPaint {
const key = `fill:${color}:${alpha}`;
const cached = this.fillCache.get(key);
if (cached) return cached;
const paint = Skia.Paint();
paint.setAntiAlias(true);
paint.setColor(Skia.Color(color));
if (alpha !== 1) paint.setAlphaf(alpha);
this.fillCache.set(key, paint);
return paint;
}
// ...
}
The cache is keyed by (color, alpha). For a Sudoku board, there are about 20 unique combinations. The cache stabilizes at a few hundred bytes and never grows beyond that.
How to test on device
The comparison point is the scenario that generates the most jank:
- Fill an entire row/column/block quickly. Triggers
unitCompletedwith a Manhattan-distance wave of highlights. In the declarative renderer, each cell in the unit is a<SkiaAnimationRect>withuseSharedValue. In the imperative one, it is an interpolated alpha in the draw. - Make mistakes in sequence. Each mistake creates a
mistakeoverlay with horizontal digit shake. Several quick mistakes stack overlays. - Board reveal on new game. Sequential reveal of all 81 cells with fade-in.
- Auto-complete assist. Automatic fill waves.
The comparison should look at consistent frame time (no spikes) on modest devices. On a modern iPhone, all three renderers are indistinguishable; the gain shows up where the reconciler was the bottleneck.
When to use imperative Skia vs declarative
Declarative Skia (<Canvas> with React nodes) is the right choice for most cases. It is more readable, keeps React’s power (props, hooks, context), and the reconciler overhead is invisible up to a few dozen nodes.
Imperative Skia (PictureRecorder + <Picture>) is worth it when:
- You have hundreds of visual objects that change every frame.
- The reconciler dominates frame time (measure before migrating; do not guess).
- You need coordinated animations across many elements (waves, particles, grids).
For a Sudoku board with 81 cells that changes on every tap, the boundary is thin. On modern devices, declarative is enough. On modest devices, imperative removes the overhead and stabilizes frame time. The choice became a config in the app, not an irreversible decision.
What remained
The canvas-imperative renderer is a third option in the app, selectable in Settings. It coexists with canvas (declarative Skia) and native (React Native views). The default stays canvas. Anyone who wants to try imperative on an old device can switch; anyone who does not, notices nothing.
The takeaway worth keeping for other projects: @shopify/react-native-skia 2.6.7 does not have useDrawCallback, but it has PictureRecorder + <Picture>, and that pair is the path to pure imperative drawing without abandoning <Canvas>. But the SkPicture you pass to <Picture> must come from useState, not useRef — React does not observe ref mutations, and the board stays stale without anyone noticing until a tester says “the touch does not match my finger.”
Related postThe rounded border that pushed every Sudoku digit off-center8 min readWritten by: Jonathan Reis on