skip to content

A Vue 3 component keeps a charting library's chart instance in `ref()`, and calls on it start throwing or slowing down - why, and how should it be held?

level: seniorimportance: should knowfreq 38%

answer

  1. ref() converts objects deeply
  2. methods run with the proxy as this
  3. private fields and identity checks
  4. plain variable, shallowRef or markRaw

basics

~20 s

ref() turns the instance into a deep reactive proxy, so library methods run with the proxy as this: private fields throw, identity checks fail, and internal reads and writes pay for tracking. Keep it in a plain variable, a shallowRef, or markRaw it.

solid answer

~40 s

`ref(obj)` passes an object through `reactive()`, and a class instance is proxied like any plain object. Calling `chart.value.update()` then runs the method with the **proxy** as `this`. JavaScript private fields (`#x`) reject a proxy with a TypeError; identity checks (`this === registered`, WeakMap lookups keyed by the real instance) fail; every internal property read goes through a trap, nested objects get wrapped, and internal writes can trigger Vue effects. The fix depends on who needs to react: if nothing in the template or a watcher depends on the instance, keep it in a plain `let` in setup; if something must notice the instance being created or replaced, use `shallowRef()`; if it has to live inside reactive state, `markRaw()` it first. Also pass the library raw data, not proxies.

code

vue · 26 lines
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
import { Chart } from './chart-lib' // any third-party charting class

const props = defineProps<{ series: number[] }>()
const canvas = useTemplateRef<HTMLCanvasElement>('canvas')

// shallowRef: the template reacts to the chart existing, the instance stays unproxied
const chart = shallowRef<Chart | null>(null)

onMounted(() => {
  chart.value = new Chart(canvas.value!, { data: [...props.series] })
})

watch(
  () => props.series,
  (series) => chart.value?.setData([...series]), // plain copy, not a proxy
)

onBeforeUnmount(() => chart.value?.destroy())
</script>

<template>
  <canvas ref="canvas" />
  <p v-if="!chart">Loading chart...</p>
</template>

go deeper

for a junior

Recall that ref() makes objects deeply reactive, which is not wanted for objects created by third-party libraries.

for a middle

Explain how the proxy becomes this inside library methods, and why private fields, identity checks and tracking suffer.

for a senior

Pick plain variable, shallowRef or markRaw by who must react, keep the data boundary raw, and diagnose wrapped instances with isProxy.

for a principal

Set a codebase rule for integrating stateful third-party objects so wrappers, lifecycles and data boundaries are consistent across teams.

## What `ref()` does to an object `ref(value)` stores an object value after converting it with `reactive()`. Vue proxies any value whose type reports as a plain `Object` or `Array` (plus `Map`, `Set`, `WeakMap`, `WeakSet`), and an ordinary class instance reports as `Object`. So: ```ts const chart = ref<Chart>() onMounted(() => { chart.value = new Chart(canvas.value!, config) }) ``` stores a **reactive proxy** of the chart. `chart.value` hands the proxy back on every read, and every nested object the library reaches through it is proxied lazily too. ## The symptoms and their causes | Symptom | Cause | |---|---| | `TypeError` mentioning a private member | a method using `#privateField` runs with the proxy as `this`, and private fields only accept the real instance | | library cannot find its own objects | internal `===` checks or `WeakMap` lookups compare the proxy against the raw instance it registered | | animation or zoom gets slow | every internal property read goes through a proxy trap; nested objects are wrapped on access | | unexpected re-renders or loops | internal writes through the proxy call `trigger()`, re-running effects that read those properties | None of this is a library bug. A proxy is a different object that forwards operations, and code written against the real instance does not expect it. ## Three ways to hold it 1. **A plain variable** - `let chart: Chart | undefined` inside `<script setup>`. The component's lifecycle hooks and watchers can call it; nothing reactive notices it. This is the simplest option when no template expression, computed or watcher depends on the instance itself. 2. **`shallowRef()`** - `const chart = shallowRef<Chart>()`. Only `.value` replacement is reactive; the instance is stored as-is. Choose it when something must react to the instance appearing or being swapped, such as a `v-if` that shows controls once the chart exists. 3. **`markRaw()`** - `state.chart = markRaw(new Chart(...))`. Needed when the instance must sit inside a `reactive()` object or a store; the skip flag makes nested access return the real instance. The docs' guidance on `markRaw()` names exactly this case: some values, such as a complex third-party class instance, simply should not be made reactive. ## The other direction: data you pass in Feeding the library reactive data creates the same problems in reverse. The library may iterate the data (every read goes through traps) or mutate it (triggering Vue effects behind your back). Hand it plain data at the boundary: - `chart.setData(toRaw(series.value))` for a quick unwrap of a reactive array at the call site; - a deep copy when the library keeps and mutates what it receives; - a `watch` on your reactive source that pushes updates into the instance, so Vue owns the data and the library owns its internals. ## Lifecycle hygiene Create the instance in `onMounted` (the DOM element exists) and call its destroy method in `onBeforeUnmount` or `onUnmounted`. A proxied instance often breaks destroy too, since cleanup code is where libraries lean hardest on internal identity. ## Diagnosing it in an existing codebase - Log `isProxy(chart.value)`; `true` confirms the instance was wrapped. - Search for `ref(` and `reactive(` holding instances from third-party constructors; these are the usual suspects. - If a fix must be minimal, `markRaw()` at the construction site removes the proxy for every holder at once. ## Why `shallowRef` rather than `markRaw` in a component Both keep the instance unproxied, but they say different things. `markRaw()` changes the **object**: wherever it goes, into a store, a provided value or a prop, it stays raw. `shallowRef()` changes the **holder**: the instance is raw only as long as it lives in that ref. Inside a single component, `shallowRef()` is usually the clearer choice because the decision stays local and visible where the state is declared. When the instance is created in one place and travels through shared state, `markRaw()` at construction is safer, because every later holder inherits the opt-out. ## Interview summary The answer has three parts: **why** (deep conversion makes the instance a proxy, and code relying on `this`, private fields and identity breaks), **how to hold it** (plain variable, `shallowRef`, or `markRaw`, chosen by who needs to react) and **the data boundary** (give the library raw data).

  • When would you choose `shallowRef()` over a plain variable for a library instance in Vue 3?
    When something reactive depends on the instance itself - a template `v-if`, a computed or a watcher that must run once it exists or is replaced. `shallowRef()` tracks only `.value` replacement, so assigning a new instance notifies those readers while the instance stays unproxied.
  • Why can passing reactive arrays into the library cause trouble even when the instance itself is raw?
    The library then works on proxies: its reads go through traps, it may store references to proxies, and any in-place mutation it performs triggers Vue effects unexpectedly. Hand it `toRaw()` output or a copy at the boundary.

saying these in an interview costs you the question

  • Class instances are never proxied by ref(), only plain objects are.
  • The library is buggy; a proxy behaves identically to the real object.
  • toRaw() on the new instance before assigning it to a ref keeps it raw.
  • Every value used in setup must be a ref, so the instance needs one.
  • readonly() around the instance stops Vue from proxying it.