skip to content

A Vue 3 component redraws a canvas from reactive points in a default watchEffect(); the canvas is null on the first run and drawings vanish on resize - why, and what do you change?

level: seniorimportance: should knowfreq 35%

answer

  1. first run happens during setup
  2. the patch comes after the draw
  3. resizing a canvas clears it
  4. move the effect after the DOM

basics

~20 s

A default watchEffect() first runs synchronously in setup, before the canvas exists, and later runs before the DOM update, so a width change patched afterwards clears the drawing. Use watchPostEffect() so every draw runs after Vue has updated the DOM.

solid answer

~40 s

With the default `'pre'` flush, `watchEffect()` runs its first time synchronously during setup, before the template has mounted, so the template ref to the `<canvas>` is still `null`. Later runs are scheduled before the owner component's DOM update. If the template binds `:width` or `:height` to reactive size, a resize runs the draw first, then Vue patches the new width onto the canvas - and setting a canvas's width or height resets its bitmap, erasing what was just drawn. Switching to `watchPostEffect()` (or `flush: 'post'`) fixes both: the first run waits until after mount, when template refs have been assigned, and every later run happens after the size attribute has been patched, so the drawing is made on the final canvas.

code

vue · 19 lines
vue
<script setup lang="ts">
import { useTemplateRef, watchPostEffect } from 'vue'

const props = defineProps<{ points: number[]; width: number; height: number }>()
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')

watchPostEffect(() => {
  const ctx = canvas.value?.getContext('2d')
  if (!ctx) return
  ctx.clearRect(0, 0, props.width, props.height)
  ctx.beginPath()
  props.points.forEach((y, x) => ctx.lineTo(x * 10, props.height - y))
  ctx.stroke()
})
</script>

<template>
  <canvas ref="canvas" :width="width" :height="height" />
</template>

go deeper

for a junior

Know that a default watchEffect runs during setup before the DOM exists, and that watchPostEffect waits until the DOM is ready.

for a middle

Explain the order: pre watcher, then the owner's patch, then post watchers, and why the first run of a post effect is deferred until after mount.

for a senior

Trace a vanishing-drawing bug to a width patch after the draw, fix it with 'post' timing, and explain why onMounted plus a watcher is a weaker fix.

for a principal

Make 'DOM-touching effects declare post timing' a reviewable rule, and prefer one effect that owns resize and draw together.

## The component A chart component draws reactive `points` on a `<canvas>` whose size is bound in the template: ```vue <script setup lang="ts"> const props = defineProps<{ points: number[]; width: number; height: number }>() const canvas = useTemplateRef<HTMLCanvasElement>('canvas') watchEffect(() => { const ctx = canvas.value?.getContext('2d') if (!ctx) return draw(ctx, props.points, props.width, props.height) }) </script> <template> <canvas ref="canvas" :width="width" :height="height" /> </template> ``` Two problems show up: the first run finds no canvas, and after a resize the canvas is blank. ## Bug 1: the first run happens before the canvas exists - With the default `flush: 'pre'`, `watchEffect()` runs its function **synchronously when it is created** - during setup. - Setup runs **before** the template renders, so the template ref's value is still `null`. Without the `?.` guard, `canvas.value.getContext` throws on that first run. - With the guard, the chart does get drawn - but only by accident: the first run read `canvas.value`, so the template ref became a dependency, and assigning the element to it after mount re-triggers the effect. The code works because of a guard and an incidental dependency, and it spends a run doing nothing. Many developers instead reach for `onMounted` plus a watcher, which duplicates the drawing call. ## Bug 2: the draw runs before the resize is patched - When `width` changes, both the effect (it read `props.width`) and the component's render are triggered. - A `'pre'` watcher runs **before** the owner component's DOM update. So the order is: draw on the old-size canvas, then Vue patches `width` onto the element. - In the browser, **setting a canvas's `width` or `height` resets its bitmap**, even to the same size. The freshly drawn content is wiped by Vue's patch, which is doing exactly what the template asked. ## The fix: run the effect after the DOM update ```ts watchPostEffect(() => { const ctx = canvas.value?.getContext('2d') if (!ctx) return draw(ctx, props.points, props.width, props.height) }) ``` `watchPostEffect()` is `watchEffect()` with `flush: 'post'`: 1. **First run deferred.** A post-flush `watchEffect` does not run synchronously in setup; its first run is queued in the post-render queue, after the component has mounted. Template refs are assigned in that same post-render phase, ahead of post-flush watchers, so `canvas.value` is the element. 2. **Later runs after the patch.** On a resize, Vue first patches the new `width`/`height` (clearing the bitmap), then runs the effect, which draws onto the final canvas. 3. **Tracking is unchanged.** It still tracks `props.points`, `props.width`, `props.height` and the template ref. ## Alternatives and why they are weaker | Approach | Problem | |---|---| | `onMounted(draw)` + `watch(..., draw)` | two code paths; the watcher still runs pre-update unless given `flush: 'post'` | | `await nextTick()` inside a default effect | reads after the `await` are not tracked; easy to break | | `watchSyncEffect()` | runs inside each mutation, before any patch - the same bug, more often | | Drawing in `onUpdated` | runs on every re-render of the component, including unrelated ones | ## What still needs care after the fix - **Clear before drawing.** Only a size change resets the bitmap; a redraw caused by new `points` alone draws over the old picture, so start with `clearRect`. - **Batching is per tick, not per frame.** Several changes in one tick produce one post-flush run, but a stream of updates across ticks can still redraw more often than the screen refreshes; throttle at the data source if that matters. - **Release outside resources.** If the effect also sets up a resize observer or a timer, register its teardown with the watcher's cleanup so it does not accumulate across runs. ## General rule - A watcher whose side effect **reads or writes the owner component's DOM** should declare `'post'` timing. - A watcher that only computes or fetches can stay on the default. - On the server there is no DOM; post-flush effects are not run during SSR, which suits canvas code.

  • Would watch([() => props.points, () => props.width], draw, { flush: 'post' }) work as well in Vue 3?
    Yes, for the resize bug: flush 'post' applies to watch() too, so the callback runs after the patch. But watch() is lazy, so you would also need `immediate: true` - and an immediate first run happens synchronously during setup, before mount, bringing back the null-canvas problem. watchPostEffect() handles both cases with one declaration.
  • Why not just avoid binding width in the template for a Vue 3 canvas component?
    You can set the size imperatively inside the post-flush effect instead, which also puts the resize and the draw in one place. It is a valid design, but the bound attribute is not the bug; the bug is drawing before Vue applies it. Keeping the binding and moving the effect to 'post' is the smaller change.

saying these in an interview costs you the question

  • A default watchEffect() first runs after the component has mounted.
  • Vue re-creates the canvas element whenever its width changes.
  • flush: 'sync' fixes the vanishing drawing because it runs sooner.
  • The canvas clears because Vue re-renders it twice per change.
  • Template refs are already set when setup-time code runs.