JEPA4Japan · tutorials

Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary

3,184 words 15 min read #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Drive rendering through invalidation and keep exactly one active engine, observer, listener set, and rAF loop under React Strict Mode.

Course progress Course outline 18 of 18 lessons available

Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates

  1. 01 Chapter 1: Do Not Draw Yet—Canvas Is Not a Product Architecture available now
  2. 02 Chapter 2: A Sheet of Pixels That Forgets available now
  3. 03 Chapter 3: Turn Drawing into a Replayable Recipe available now
  4. 04 Chapter 4: Four Maps and a Camera available now

Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools

  1. 05 Chapter 5: Give the Pixel World a Registry available now
  2. 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary Current lesson
  3. 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
  4. 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
  5. 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now

Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery

  1. 10 Chapter 10: Make the Editor Feel Right available now
  2. 11 Chapter 11: Drawn Text Is Not Editable Text available now
  3. 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
  4. 13 Chapter 13: Time Machines and Old Boxes available now
  5. 14 Chapter 14: Looking Correct Is Not Being Correct available now

Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI

  1. 15 Chapter 15: Do Not Search Ten Thousand Children One by One available now
  2. 16 Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades available now
  3. 17 Chapter 17: Build the Car or Buy a Proven Chassis? available now
  4. 18 Chapter 18: People and AI Edit the Same Ledger available now

Start with a game a five-year-old can understand

Build a small stage from blocks: blue paper is the background, three dolls are the actors, and a yellow circle on transparent film means “who is selected.” Ask one child to be the stage manager and another to be the ticket seller. What happens if the ticket seller shouts, “All actors, perform the whole show again!” every time an audience member blinks? Everyone becomes too busy to sell tickets. Instead, light the corresponding lamp only when the background, actors, or yellow circle truly changes. The manager sees the lamp and handles it once before the next curtain rises.

First predict: if the “yellow-circle lamp” lights ten times in the same second, should the curtain rise ten times or once? After the manager goes home, can the lamp still summon them? When an animated toy car keeps moving, how should the lamp remain lit?

  1. React ticket hallToolbar and Inspector
  2. Light a dirty lampRecord which layer changed
  3. Raise the next curtainMerge repeated notifications
  4. Close down symmetricallyCancel lamps, listeners, and Observers
Product UI runs the ticket office; the Engine runs the high-frequency stage. Redraw only when a lamp is lit.

The single truth of this chapter is: React manages product UI; the Canvas Engine manages high-frequency interactions and drawing. The boundary between them must be explicit.

Translate the toys into Canvas engineering

Stage-game conceptEngineering conceptOwner
Ticket hallReact / Next.js UIToolbar, Inspector, Dialog
Stage kernelImperative Canvas EngineDocument, Input, Camera, Renderer
Red, yellow, and blue lampsDirty Flag / Change SetRender Scheduler
Next curtainrequestAnimationFrame()Browser frame scheduling
Background paper, actors, transparent circleLayered Canvas / Render PassRenderer
Animation stopwatchAnimation ClockScheduler/Simulation
“2 selected” on the ticket boardLow-frequency Derived StateExternal Store → React
Manager starting/ending worksetup / cleanupEffect lifecycle

The analogy has limits. Browsers do not promise that requestAnimationFrame runs exactly every 16.67ms; background tabs commonly pause it or reduce its frequency. React Strict Mode’s development check does not mean “two managers work forever.” It performs an extra setup→cleanup→setup to verify symmetry. Layered Canvas may mean several physical <canvas> elements or several Passes in one Canvas. The former can reduce local redraws but increases compositing, dimension synchronization, and memory costs.

Kill the misleading intuitions first

  • “Rebuild the Engine on every React render because that is most declarative.” The Engine owns listeners, Observers, and rAF. Repeated construction loses the Document and leaks resources.
  • “Call setState on every pointermove so the Inspector stays live.” A 240Hz input stream must not drive the whole product UI. The Engine updates transient state internally and publishes only low-frequency snapshots with product value.
  • “A permanent rAF loop is simplest.” It wastes power in a static editor. Default to Invalidation-Based rendering and become Continuous only during a real animation.
  • “Strict Mode double initialization is a React bug; remove StrictMode.” It reveals asymmetric cleanup. Fix resource ownership.
  • “After returning from the background, catch up every animation with one huge dt.” That causes jumps or a simulation spiral. Clamp variable timestep, and limit fixed-timestep catch-up steps too.
  • “A Client Component can receive any Server object.” Props across the Server/Client Boundary must be serializable. Contexts, functions, Maps, and Engine instances cannot cross it.

Production backpack

Prerequisite contract

The prerequisite is the RuntimeDocument/Session separation from Chapter 5. At construction, the Engine receives an existing Canvas, an Overlay root, and a read-only Document Store. Destruction is idempotent. React receives only an initial JSON-serializable WireDocument and creates the Engine after mounting. The Engine publishes a stable DerivedSnapshot={selectionCount,canUndo,saveStatus}; it does not send Pointer coordinates or the entire Document to React every frame.

Formal knowledge: Scheduler

requestAnimationFrame(callback) schedules a callback before the browser’s next repaint, with a timestamp from a shared high-resolution time base. A callback is one-shot; Continuous Rendering must request another. Invalidation-Based Rendering requests a frame when state becomes dirty and coalesces repeated invalidations. A Dirty Flag can use a bitmask for Background, Scene, and Overlay. A Change Set carries which Records changed and can later let a Layered Canvas or cache update only the necessary parts.

Render Passes retain Chapter 3’s order. Multiple physical Canvases must share Host dimensions and Camera. By default, a static Scheduler has no pending frame. When animation starts, continuous=true; after it stops, no new request is made if nothing is dirty. The heart of Duplicate Loop Prevention is a single stored pending rAF ID: if it is non-null, do not request another.

An Animation Clock does not “add 1 every frame.” Variable Timestep passes a capped dt to visual animation. Fixed Timestep uses an accumulator to update a deterministic simulation in fixed steps, then interpolates for drawing. After a background tab pauses, dt may be enormous. Limit the maximum delta and maximum catch-up steps, and optionally use visibility changes to rebase time. Time policy belongs to the Engine, not React state.

Formal knowledge: React / Next.js

With the Next.js App Router, mark the entry file that uses DOM/Canvas with 'use client'. A ref supplies Canvas and Overlay DOM nodes. useLayoutEffect suits Host/Engine setup that must finish before browser paint; ordinary network subscriptions or telemetry can use useEffect. Both require symmetric setup/cleanup. During development, Strict Mode runs an extra Effect setup and cleanup. The correct result is at most one Active Engine/Loop at any instant.

An External Store exposes a low-frequency stable snapshot to React through useSyncExternalStore(subscribe,getSnapshot,getServerSnapshot). When data is unchanged, getSnapshot must return the same object to avoid an infinite render. A DOM Overlay can use ordinary absolutely positioned children, or createPortal can place a text editor inside the Overlay root. Visual position comes from low-frequency/on-demand Engine projection, while focus and input remain managed by the DOM.

The Imperative Engine Lifecycle is: create on Effect mount → bind Document subscription/Pointer/Resize → perform the first invalidate. Cleanup cancels rAF, unsubscribes, removes listeners, and destroys the Host. A React rerender is not a mount and must not rebuild the Document. During SSR, module top level must not access window, document, devicePixelRatio, or getContext.

Evidence and compatibility

Sources above were checked on 2026-08-29. Background rAF policy is browser-defined, so tests must not depend on a fixed frequency. React development checks differ from production builds, but cleanup symmetry must hold in both.

This chapter’s engineering increment

Starting point: a React component draws directly and writes state on every Pointer Move. Finish line: an independent Scheduler/Engine is testable in Node; the Shell owns only the DOM host and low-frequency subscription; there is one Active Loop at any instant.

canvas-lab/src/lab/ch06/
  render-scheduler.ts
  canvas-engine.ts
  CanvasEditorShell.tsx
  render-scheduler.test.ts
  CanvasEditorShell.test.tsx

The complete Scheduler expresses invalidation, continuous mode, fixed/variable clocks, and idempotent destruction together:

export const Dirty = { Background: 1, Scene: 2, Overlay: 4, All: 7 } as const;
export type Frame = {
  now: number;
  variableDt: number;
  dirty: number;
  fixedSteps: number;
  alpha: number;
};
export type FramePlatform = {
  request(callback: FrameRequestCallback): number;
  cancel(id: number): void;
};
const browserFrames: FramePlatform = {
  request: (callback) => window.requestAnimationFrame(callback),
  cancel: (id) => window.cancelAnimationFrame(id),
};

export class RenderScheduler {
  private pending: number | null = null;
  private dirty = 0;
  private continuous = false;
  private disposed = false;
  private lastNow: number | null = null;
  private accumulator = 0;
  private readonly fixedStep = 1000 / 60;

  constructor(
    private readonly render: (frame: Frame) => void,
    private readonly platform: FramePlatform = browserFrames,
  ) {}

  invalidate(mask: number): void {
    if (this.disposed) return;
    this.dirty |= mask;
    this.ensureFrame();
  }
  setContinuous(value: boolean): void {
    if (this.disposed || this.continuous === value) return;
    this.continuous = value;
    if (value) this.ensureFrame();
  }
  rebaseClock(): void {
    this.lastNow = null;
    this.accumulator = 0;
  }
  dispose(): void {
    if (this.disposed) return;
    this.disposed = true;
    if (this.pending !== null) this.platform.cancel(this.pending);
    this.pending = null;
    this.dirty = 0;
    this.continuous = false;
    this.rebaseClock();
  }
  private ensureFrame(): void {
    if (this.pending !== null || this.disposed) return;
    this.pending = this.platform.request((now) => this.onFrame(now));
  }
  private onFrame(now: number): void {
    this.pending = null;
    if (this.disposed) return;
    const rawDt = this.lastNow === null ? 0 : now - this.lastNow;
    const variableDt = Math.max(0, Math.min(100, rawDt));
    this.lastNow = now;
    this.accumulator = Math.min(250, this.accumulator + variableDt);
    let fixedSteps = 0;
    while (this.accumulator >= this.fixedStep && fixedSteps < 5) {
      this.accumulator -= this.fixedStep;
      fixedSteps += 1;
    }
    if (fixedSteps === 5 && this.accumulator >= this.fixedStep) {
      this.accumulator %= this.fixedStep;
    }
    const dirty = this.dirty;
    this.dirty = 0;
    this.render({ now, variableDt, dirty, fixedSteps, alpha: this.accumulator / this.fixedStep });
    if (this.continuous || this.dirty !== 0) this.ensureFrame();
  }
}

After five Fixed Steps, discard the whole-step backlog and retain only the remainder smaller than one step. Otherwise, “at most five catch-up steps per frame” can still carry the background debt into many later frames and create a slower catch-up spiral. If business logic cannot drop steps, pause and resynchronize to authoritative time instead of catching up forever.

The Engine owns high-frequency resources. A Change Set maps to layers, and the Snapshot object is replaced only when values change:

import { Dirty, RenderScheduler, type FramePlatform } from './render-scheduler';
export type DocumentChangeSet = Readonly<{
  backgroundChanged: boolean;
  changedShapeIds: readonly string[];
}>;
export type DocumentStore = {
  getSnapshot(): unknown;
  subscribe(listener: (change: DocumentChangeSet) => void): () => void;
};
export type DerivedSnapshot = Readonly<{
  selectionCount: number;
  canUndo: boolean;
  saveStatus: 'saved' | 'dirty';
}>;
export class CanvasEngine {
  private scheduler: RenderScheduler;
  private snapshot: DerivedSnapshot = { selectionCount: 0, canUndo: false, saveStatus: 'saved' };
  private listeners = new Set<() => void>();
  private abort = new AbortController();
  private unsubscribeDocument: () => void;
  private destroyed = false;

  constructor(
    private readonly canvas: HTMLCanvasElement,
    documentStore: DocumentStore,
    private readonly renderPasses: (dirty: number) => void,
    frames?: FramePlatform,
  ) {
    this.scheduler = new RenderScheduler((frame) => this.renderPasses(frame.dirty), frames);
    this.unsubscribeDocument = documentStore.subscribe(this.onDocumentChange);
    canvas.addEventListener('pointermove', this.onPointerMove, { signal: this.abort.signal });
    document.addEventListener('visibilitychange', this.onVisibility, { signal: this.abort.signal });
    this.scheduler.invalidate(Dirty.All);
  }
  private onPointerMove = (_event: PointerEvent): void => {
    if (!this.destroyed) this.scheduler.invalidate(Dirty.Overlay);
  };
  private onVisibility = (): void => {
    if (document.visibilityState === 'visible') {
      this.scheduler.rebaseClock();
      this.scheduler.invalidate(Dirty.All);
    }
  };
  private onDocumentChange = (changeSet: DocumentChangeSet): void => {
    this.publish({ ...this.snapshot, canUndo: true, saveStatus: 'dirty' });
    let dirty = Dirty.Overlay;
    if (changeSet.backgroundChanged) dirty |= Dirty.Background;
    if (changeSet.changedShapeIds.length > 0) dirty |= Dirty.Scene;
    this.scheduler.invalidate(dirty);
  };
  setSelection(ids: ReadonlySet<string>): void {
    this.publish({ ...this.snapshot, selectionCount: ids.size });
    this.scheduler.invalidate(Dirty.Overlay);
  }
  subscribe = (listener: () => void): (() => void) => {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  };
  getSnapshot = (): DerivedSnapshot => this.snapshot;
  private publish(next: DerivedSnapshot): void {
    if (
      next.selectionCount === this.snapshot.selectionCount &&
      next.canUndo === this.snapshot.canUndo &&
      next.saveStatus === this.snapshot.saveStatus
    )
      return;
    this.snapshot = next;
    for (const listener of this.listeners) listener();
  }
  destroy(): void {
    if (this.destroyed) return;
    this.destroyed = true;
    this.unsubscribeDocument();
    this.abort.abort();
    this.scheduler.dispose();
    this.listeners.clear();
  }
}

Here, DocumentStore.subscribe is the read-only subscription port of Chapter 5’s Command Boundary. The Engine does not mutate records directly. A Change Set is more than one “changed” boolean: background changes and Shape ID changes map to different Dirty Layers, which later makes Layered Canvas, caching, and local invalidation safe. A production Store should also make its unsubscribe function idempotent; Engine destroy() guarantees that it is called only once.

The Shell is the Client Boundary; initialDocument is JSON, not a Runtime Map. The following code shows all four required layers and the Portal:

'use client';
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { CanvasEngine, type DerivedSnapshot, type DocumentStore } from './canvas-engine';

type WireDocument = { records: readonly { id: string; type: string }[] };
const empty: DerivedSnapshot = { selectionCount: 0, canUndo: false, saveStatus: 'saved' };
const subscribeEmpty = (): (() => void) => () => undefined;
const getEmpty = (): DerivedSnapshot => empty;
const staticStore = (document: WireDocument): DocumentStore => ({
  getSnapshot: () => document,
  subscribe: () => () => undefined,
});
const defaultCreateEngine = (canvas: HTMLCanvasElement, document: WireDocument): CanvasEngine =>
  new CanvasEngine(canvas, staticStore(document), () => undefined);

export function CanvasEditorShell({
  initialDocument,
  createEngine = defaultCreateEngine,
}: {
  initialDocument: WireDocument;
  createEngine?: (canvas: HTMLCanvasElement, document: WireDocument) => CanvasEngine;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const overlayRef = useRef<HTMLDivElement>(null);
  const [engine, setEngine] = useState<CanvasEngine | null>(null);
  useLayoutEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const next = createEngine(canvas, initialDocument);
    setEngine(next);
    return () => {
      next.destroy();
    };
  }, [createEngine, initialDocument]);
  const snapshot = useSyncExternalStore(
    engine?.subscribe ?? subscribeEmpty,
    engine?.getSnapshot ?? getEmpty,
    getEmpty,
  );
  return (
    <section className="canvas-editor-shell">
      <div className="canvas-host">
        <canvas ref={canvasRef}>Business-process canvas</canvas>
      </div>
      <div ref={overlayRef} className="dom-overlay-layer" />
      <nav className="toolbar" aria-label="Canvas tools">
        <button type="button">Select</button>
        <button type="button">Rectangle</button>
      </nav>
      <aside className="inspector" aria-live="polite">
        {snapshot.selectionCount} selected; {snapshot.saveStatus}
      </aside>
      {overlayRef.current
        ? createPortal(<textarea aria-label="Text being edited" />, overlayRef.current)
        : null}
    </section>
  );
}

defaultCreateEngine must be defined outside the component. If a default arrow function is written in the parameter list, every render creates a new reference. The setEngine following Engine creation triggers the next render; the changed dependency then destroys and rebuilds the Engine in a loop. In a real application, custom createEngine and initialDocument references must also remain stable. A Server Component passes only plain JSON. If a parent creates a new object every time, load the Store by document ID; do not suppress Effect dependencies to ignore real data updates. Cleanup does not write React state during unmount. It only destroys the instance that this setup actually created.

A fake-frame Scheduler test proves that ten invalidations make only one request and destruction cancels it:

import { describe, expect, it, vi } from 'vitest';
import { Dirty, RenderScheduler, type FramePlatform } from './render-scheduler';
describe('RenderScheduler', () => {
  it('coalesces dirty changes and owns one active request', () => {
    let callback: FrameRequestCallback | null = null;
    const cancel = vi.fn();
    const platform: FramePlatform = {
      request: vi.fn((cb) => {
        callback = cb;
        return 41;
      }),
      cancel,
    };
    const render = vi.fn();
    const scheduler = new RenderScheduler(render, platform);
    for (let i = 0; i < 10; i += 1) scheduler.invalidate(Dirty.Overlay);
    expect(platform.request).toHaveBeenCalledTimes(1);
    (callback as FrameRequestCallback)(100);
    expect(render).toHaveBeenCalledWith(expect.objectContaining({ dirty: Dirty.Overlay }));
    expect(platform.request).toHaveBeenCalledTimes(1);
    scheduler.invalidate(Dirty.Scene);
    scheduler.dispose();
    scheduler.dispose();
    expect(cancel).toHaveBeenCalledTimes(1);
  });
  it('clamps a background-tab time jump', () => {
    const callbacks: FrameRequestCallback[] = [];
    const platform: FramePlatform = {
      request: (cb) => {
        callbacks.push(cb);
        return callbacks.length;
      },
      cancel: () => undefined,
    };
    const render = vi.fn();
    const scheduler = new RenderScheduler(render, platform);
    scheduler.setContinuous(true);
    callbacks.shift()!(10);
    callbacks.shift()!(10_000);
    expect(render.mock.calls[1][0].variableDt).toBe(100);
    expect(render.mock.calls[1][0].fixedSteps).toBeLessThanOrEqual(5);
    expect(render.mock.calls[1][0].alpha).toBeLessThan(1);
    scheduler.dispose();
  });
});

Run npx vitest run src/lab/ch06/render-scheduler.test.ts; expect 2 passed. With React Testing Library, mount/unmount the Shell twice inside <StrictMode>, asserting create count === destroy count and a maximum simultaneous active count of 1. Drag for five seconds under React Profiler. Canvas should update at high frequency, while Shell renders change only with selectionCount/saveStatus.

Return to the stage: the ticket seller displays only “how many selected” and “whether Undo is available.” They do not reprint every ticket whenever an actor moves a millimeter. The stage manager merges ten same-color notifications into the next curtain and returns every key when work ends.

Break it on purpose

InjectionSymptomEvidenceFixRegression testRecovery
Strict Mode setup without cleanupDouble listener/double Loop in developmentActive counter reaches 2Effect returns idempotent destroyStrict mount cycle max active=1Remove surviving instance
Mount/Unmount twiceSecond event fires twiceListener/render calls doubleEngine exclusively owns AbortControllercreate=destroy after two cyclesMount again and verify functionality
ResizeObserver not releasedCallback continues after leaving pageDetached node retainedHost.destroy disconnectsHeap/spy reaches zeroManually destroy surviving Host
Duplicate Pointer listenerOne movement invalidates several timesDuplicate Event traceUnified removal via signalOne event enters onceAbort old controller
Duplicate rAF loopHigh CPU while idle and doubled frame countPending ID/Performance traceOne-pending guardTen invalidations make one requestCancel extra ID
Read window at SSR module top levelServer build says window is not definedSSR stack trace'use client' + access inside EffectServer render/import testRemove module side effect
Pointer Move writes React StatePage renders 120–240 times/secondReact ProfilerKeep high-frequency transient state in EngineFive-second drag render budgetDelete bad state bridge
Huge background dtTeleport/freeze on returndt≈tens of thousands of msClamp, fixed-step cap, rebase10→10000 time testRestart animation clock

Enable one failure switch at a time and record the active Engine, pending rAF, listeners, Observers, and React commit count. After fixing it, run the unit tests, Strict lifecycle tests, and browser Profiler. Finally, unmount deliberately. Every count must reach zero before recovery is complete.

Pass with evidence

GateAutomated/manualEvidence
Always one Active LoopAutomatedPending-rAF unit test and active counter
Engine testable without ReactAutomatedScheduler is a pure Node test; Engine uses jsdom and has no React import
React rerender does not rebuild DocumentAutomated/manualProfiler + Engine creation count does not grow with Inspector renders
High-frequency interaction does not rerender pageManualPointer trace is high while React commit count remains low
Lifecycle is symmetricAutomatedStrict setup/cleanup, Observer/listener/rAF all 1:1
Background recovery is controlledAutomateddt clamp, fixed-steps cap, visibility-rebase tests
  • I implemented Invalidation, Dirty Flag, Change Set, and fixed Passes.
  • Continuous is enabled only during animation; no rAF exists while static.
  • Fixed/Variable Timestep and background-tab policy have deterministic tests.
  • <CanvasEditorShell> contains Host, DOM Overlay, Toolbar, and Inspector.
  • 'use client', ref, Effect/LayoutEffect, Strict Mode, External Store, Portal, and the serialization boundary each have an explicit responsibility.
  • Engine is created on mount, destroyed on unmount, and publishes only low-frequency Derived State to React.

Explain it to a five-year-old

Without using the words “React,” “Engine,” “rAF,” “Dirty Flag,” or “Effect,” answer: Why should every audience blink not make all the actors perform again? How do you ensure that after the manager goes home, a second manager is not secretly turning on lamps?

Expand a good jargon-free answer

A blink does not change the background, actors, or yellow circle, so repeating the show only wastes energy. When something changes, light the matching lamp. Even if it lights ten times in one short moment, handle all of them together at the next curtain. While an actor keeps running, the lamp can temporarily stay on; turn it off when they stop. At the start of work, the manager receives a set of keys: a timer, ruler, and doorbell. At the end of work, every item must be returned. During inspection, let the manager start work, stop immediately, and start again. If only one person holds the keys at every moment, no second manager can keep working in secret.