Drag adjustment sliders in Aesthetikk and the photo updates live, like Lightroom, instant reflect on the photo on <canvas> .
That interaction comes from two techniques:
- RAF throttling caps GPU repaints at the screen refresh rate (~60FPS)
- React architecture keeps
useEffectout of slider components
In this post, I will explain how debounce would not be a good fit for real-time rendering app + how you can tell your agent to chillout and stop using useEffect where its not neccessary. 🥰
What you see in the UI
The editor sidebar has four tabs: Presets, Effects, Advanced, and Text.
| Tab | What the user does |
|---|---|
| Presets | Tap a color preset card |
| Effects | Drag brightness, bloom, grain sliders,... |
| Advanced | Drag clarity, film fade, tone curves,... |
| Text | Text Behind Your Photo |
None of these tabs run WebGL. None of them use requestAnimationFrame. They only save numbers when you interact.
The canvas hook one layer below watches those numbers and repaints the photo. That is where RAF lives.
What not todo (and why it is silly)
The classic React mistake in a photo editor:
// Do not do this: one effect per slider
useEffect(() => { applyBrightness(brightness); }, [brightness]);
useEffect(() => { applyContrast(contrast); }, [contrast]);
useEffect(() => { applyBloom(bloom); }, [bloom]);
// repeat for every dragAbsolute hell hole.
Each effect runs on its own schedule. Brightness might fire before contrast in one frame and after it in the next. You get visual flicker and race conditions when async GPU work overlaps.
Each effect triggers a full GPU pipeline. A single drag emits 100+ store updates per second. Twenty independent effects means twenty chances to schedule twenty full WebGL runs in the same frame. The GPU melts. The preview stutters. User's phone browsers will give up.
Effects capture stale callbacks. Unless you carefully sync refs, an effect from an older render calls an outdated applyEffects with wrong closure values.
You cannot coalesce. React does not merge "brightness changed" and "contrast changed" in the same tick into one paint. You get N separate effect runs for N sliders that moved.
Using useEffect to sync UI knobs to a canvas is treating a continuous drag gesture like a discrete data fetch. Wrong tool here.
What works in React
Three layers. One direction of data flow.
React owns the UI. State stores own edit parameters. The color engine owns pixels.
Fifteen sidebar components have zero useEffect for canvas sync. Effects live in infrastructure hooks (mount, resize, wheel listener), not in slider panels.
Layer 1: Sliders only save numbers
A brightness slider reads one value and writes on drag:
const brightness = useColorEngineEffectsStore((state) => state.adjustments.brightness);
const setAdjustments = useColorEngineEffectsStore((state) => state.setAdjustments);
<Slider
value={[brightness * 100]}
onValueChange={([value]) => setAdjustments({ brightness: value / 100 })}
min={-100}
max={100}
/>That is the entire job of the slider. No WebGL import. No RAF. No effect.
Same pattern everywhere in the sidebar:
// Presets tab: click a card
setSelectedLutId(lutId);
// Effects tab: drag bloom
setBloom({ intensity: value / 100 });
// Advanced tab: drag clarity
setClarity({ intensity: value / 100 });Layer 2: Shared state as a notepad
All tabs write to the same stores. Presets set selectedLutId. Effects set brightness, bloom, grain. Advanced sets clarity and tone curves.
Think of it as a notepad everyone shares. Dragging a slider writes to the notepad 100+ times per second. React re-renders only the components subscribed to the fields that changed.
Each slider uses a granular selector so changing bloom does not re-render the brightness slider:
const brightness = useColorEngineEffectsStore((state) => state.adjustments.brightness);The canvas hook intentionally subscribes to all effect slices because it must react to any change. Centralize that into one place , we'll have better control than scattered it in all the places.
Layer 3: The only useEffect we need.
The canvas-side hook:
- Reads all slider values from stores
- Watches for changes with one
useEffect - Schedules a repaint with RAF
- Runs the GPU pipeline when the frame fires
useEffect(() => {
if (!originalImage || !colorEngine || !isWebGLReady || isCleaningUp) return;
scheduleApplyEffects();
}, [
originalImage, colorEngine, isWebGLReady, isCleaningUp, selectedLutId,
adjustments, grain, bloom, diffusion, chromatic, vignette,
toneCurvesParams, clarity, filmFade, spectrum,
scheduleApplyEffects,
]);One effect. All dependencies. Stop thinking lifecycle, start thinking synchronization. Effects keep external systems in sync with React state/props. Every render is isolated. Props, state, functions, and effects from one render don't "see" values from other renders. The dependency array is a correctness feature, not a performance hack. List all values your effect uses from component scope, don't lie in the return.
What RAF throttling is
The browser repaints the screen about 60 times per second (once every ~16ms on a typical display).
requestAnimationFrame(fn) means: run fn right before the next paint, synced to the display.
Finger on slider: ~100+ updates/sec (too many for GPU)
Screen refresh: ~60 paints/sec (the physical limit)
RAF: caps GPU work at 60/sec
The actual scheduler
const rafIdRef = useRef<number | null>(null);
const pendingApplyRef = useRef(false);
const applyCurrentEffectsRef = useRef(applyCurrentEffects);
Three refs, plain roles:
| Ref | Role |
|---|---|
rafIdRef | Is a frame already scheduled? |
pendingApplyRef | Do we need to repaint? |
applyCurrentEffectsRef | Latest paint function (avoids stale closures) |
Refs hold bookkeeping. Not useState. Scheduling a frame is not something the UI displays. Storing the RAF id in state would cause pointless rerenders.
RAF vs debounce (do not mix them up)
| While dragging | Feels like | |
|---|---|---|
| Debounce | Waits until you stop moving | Preview freezes, then jumps |
| RAF throttle | Updates during drag, max ~60/sec | Smooth live preview |
Debounce is for search boxes. Photo editing needs live feedback. RAF matches the display refresh rate instead of waiting for idle.
UX under the hood: drag brightness
1. Drag slider
2. onValueChange fires ~100 times/sec
3. setAdjustments({ brightness: 0.3 }) ← store only, no GPU
4. Canvas hook sees brightness changed → watcher effect runs
5. scheduleApplyEffects()
- pendingApplyRef = true
- if no RAF yet → requestAnimationFrame(...)
- if RAF already queued → skip (store already has latest value)
6. Next frame (~16ms) → applyCurrentEffects()
- load preset texture if selected
- run WebGL pipeline
- draw to canvas
7. Photo effect applied
Without RAF, step 6 would run 100 times per second. Full pipeline every time. Unusable on a laptop, fatal on a phone.
Tap a preset while effects are active
1. handleLutSelect(lutId) → setSelectedLutId(lutId)
2. Canvas hook effect fires (selectedLutId changed)
3. RAF → applyCurrentEffects()
4. loadCachedLutTexture(lutId) ← async, from local cache
5. GPU applies preset + any active bloom/clarity/etc.
6. Canvas updates
One click is usually one RAF cycle. Rapid preset switching uses AbortController so an older async load cannot finish after a newer one and flash wrong colors.
Both preset ID and bloom intensity live in the same stores. Tabs do not wire to each other. RAF throttles the combined repaint, not each tab separately.
Why not put RAF on every slider?
| Place | Why no RAF |
|---|---|
| Sidebar layout | Tab switching only |
| Effect sliders | Store writes only |
| Preset cards | One click, one store update |
| Advanced panels | Same as effects |
RAF belongs at the expensive end (GPU canvas), not at every drag. One throttle point coalesces all parameter changes in a frame into one pipeline run.
A reusable RAF hook exists for generic cases. The color engine hook inlines the same pattern because it also manages abort, cleanup, and async preset loading.
Refs: avoiding dependency hell in the one effect
Problem: applyCurrentEffects depends on many values. Putting it in the effect dependency array retriggers constantly or causes stale closures.
Solution: a ref updated every render, called from RAF:
const applyCurrentEffectsRef = useRef(applyCurrentEffects);
useEffect(() => {
applyCurrentEffectsRef.current = applyCurrentEffects;
});The watcher effect depends on data (brightness, bloom, etc.), not on the callback identity. RAF always invokes the latest function.
Same idea for zoom auto-fit: keep setTransform in a ref so layout effects do not depend on unstable function references.
useMemo for values that would fool the watcher
Tone curves pass a 256-point array into the GPU. Zustand returns new array references on update even when points did not move. Without guardrails, the watcher effect thinks the curve changed every render and schedules infinite GPU loops.
Fix: hash the control points, memoize the interpolated curve, only recompute when the hash changes:
const curveHash = useMemo(() => {
if (!toneCurvesEnabled) return '';
return hashFixedCurvePoints(luminancePoints);
}, [toneCurvesEnabled, luminancePoints]);
const memoizedCurve = useMemo(() => {
if (!toneCurvesEnabled) return null;
return { luminanceCurve: interpolateFixedCurve(luminancePoints) };
}, [curveHash]);Other places we skip useEffect (on purpose)
Preset click: handler only
const handleLutSelect = (lutId: string) => {
setSelectedLutId(selectedLutId === lutId ? null : lutId);
// GPU apply happens in canvas hook, not here
};Click → store → centralized watcher → RAF → GPU. Three steps, one owner of side effects.
Undo/redo: store subscription outside React
History tracking uses module-level Zustand subscriptions, not useEffect in components. Undo is cross-cutting. It must survive re-renders without re-subscribing. Initialized once on mount, torn down on unmount.
Zoom and pan: DOM event handlers
Mouse and touch move call handlers directly. The only effect in zoom controls is a native wheel listener because React's synthetic wheel cannot call preventDefault() (passive by default).
Color engine instance: ref, not state
The WebGL engine is imperative. It lives in a ref, created once on mount. Putting it in useState would re-render the tree every time the engine reference updates for no visual reason.
When useEffect IS correct here
We are not banning useEffect. We ban using it to sync sliders to pixels.
| Use case | Example |
|---|---|
| Mount/unmount lifecycle | WebGL init, history subscribe |
| DOM APIs | resize, wheel, pointer listeners |
| Async on input change | New image URL → load image |
| Ref sync | Keep latest callback without deps churn |
| Layout measurement | Panel height on mobile |
Rule: User event (onClick, onValueChange) → handler writes to store. Browser lifecycle or external API → effect is fine.
Full data flow
Canvas pixels never enter React state.
Cleanup: no stale paints
When the user uploads a new photo or leaves the editor:
cancelAnimationFrame(rafIdRef.current);
abortController.abort();Pending frames and in-flight preset loads are cancelled. A repaint from the old session cannot land on the new image.
To summarize all choices
| Choice | What we did | Why |
|---|---|---|
| Slider → canvas sync | Event handlers + one watcher effect | Avoid races and N GPU runs per frame |
| Throttle mechanism | RAF, not debounce | Live preview while dragging |
| Throttle location | Canvas hook only | Coalesce all tabs into one pipeline run |
| Store writes | Unlimited speed | Cheap; GPU is the bottleneck |
| GPU paints | Max ~60/sec | Matches display, protects mobile |
| Stale closures | Ref to latest apply fn | RAF runs after render with fresh data |
| Tone curves | Hash + useMemo | Prevent infinite watcher loops |
| Sidebar components | Zero canvas effects | Simple, testable, no WebGL imports |
| Undo history | Zustand subscribe at module scope | No per-component re-subscribe |