Skip to the solution
HardFrontend9 min read

When should you use shallowRef / shallowReactive / triggerRef?

398 views

The problem

Question presented to candidate: "Our dashboard loads a 20,000-row report into a ref and the page stutters. A colleague suggests shallowRef. When is that the right call, when is it a trap, and where does triggerRef fit in?"

What a strong answer should cover:

  • Deep ref/reactive creates a Proxy for every nested object you touch and a dependency for every property you read; on large data that cost is real and measurable.
  • shallowRef fits data that is replaced as a whole: API responses, immutable snapshots, external store state, big read-only lists.
  • shallowReactive fits flat state objects whose top-level keys change but whose nested values are treated as immutable.
  • triggerRef is for deliberate in-place mutation of a shallowRef (for example many edits batched, then one trigger).
  • The trap: nested edits silently do not update the UI, and computed values derived from the data go stale until a trigger.
  • Do not use shallow state for deeply edited data such as forms; measure before optimising.

Clarifying questions expected:

  • "Is the data edited in place by the UI, or replaced from the server?"
  • "Have we measured where the time goes: reactivity, rendering, or DOM size?"

Code / implementation expected: Yes β€” load a large list both ways, then show mutate-only vs triggerRef vs an immutable replacement.

  • shallowRef
  • performance
  • reactivity

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 9 sections

Read the code

Vue20,000 rows: ref vs shallowRef, and three ways to update a shallowRef
<script setup>
import { computed, ref, shallowRef, triggerRef } from "vue";

const makeRows = (n) =>
  Array.from({ length: n }, (_, i) => ({ id: i, price: (i % 50) + 1, meta: { tags: ["a", "b"] } }));

// The same 20,000-row "API response" held two ways.
const deepRows = ref([]);
const flatRows = shallowRef([]);

const deepTotal = computed(() => deepRows.value.reduce((s, r) => s + r.price + r.meta.tags.length, 0));
const flatTotal = computed(() => flatRows.value.reduce((s, r) => s + r.price + r.meta.tags.length, 0));

const timings = ref("");
function load() {
  const t0 = performance.now();
  deepRows.value = makeRows(20000);
  deepTotal.value; // force the computed to read every row through proxies
  const t1 = performance.now();
  flatRows.value = makeRows(20000);
  flatTotal.value; // plain objects: no proxies created
  const t2 = performance.now();
  timings.value = `ref: ${(t1 - t0).toFixed(1)} ms, shallowRef: ${(t2 - t1).toFixed(1)} ms`;
}

// Three ways to change one price inside the shallowRef:
function mutateOnly() {
  flatRows.value[0].price += 1000; // invisible: no trigger
}
function mutateAndTrigger() {
  flatRows.value[0].price += 1000;
  triggerRef(flatRows); // "I mutated in place, re-run whoever depends on this"
}
function replaceImmutably() {
  const [first, ...rest] = flatRows.value;
  flatRows.value = [{ ...first, price: first.price + 1000 }, ...rest]; // new array = trigger
}
</script>

<template>
  <button id="load" @click="load">load 20,000 rows</button>
  <p id="timings">{{ timings }}</p>
  <p id="totals">deep total: {{ deepTotal }} | shallow total: {{ flatTotal }}</p>
  <button id="mutate" @click="mutateOnly">mutate only</button>
  <button id="trigger" @click="mutateAndTrigger">mutate + triggerRef</button>
  <button id="replace" @click="replaceImmutably">replace immutably</button>
</template>
Mirroring an immutable external store with shallowRef
// useExternalStore.js: mirror an immutable external store into Vue.
import { onScopeDispose, shallowRef } from "vue";

export function useExternalStore(store) {
  // The store already produces a NEW snapshot object on every change,
  // so deep proxies would be pure overhead: a shallowRef is enough.
  const snapshot = shallowRef(store.getState());
  const unsubscribe = store.subscribe(() => {
    snapshot.value = store.getState(); // new identity => one trigger
  });
  onScopeDispose(unsubscribe); // stop listening with the component or scope
  return snapshot;
}

// A minimal immutable store (stand-in for Redux, Zustand vanilla, XState...).
export function createStore(initial) {
  let state = initial;
  const listeners = new Set();
  return {
    getState: () => state,
    setState: (fn) => { state = fn(state); listeners.forEach((l) => l()); },
    subscribe: (l) => (listeners.add(l), () => listeners.delete(l)),
    listenerCount: () => listeners.size,
  };
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 86 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextHard

What are common reactivity caveats in Vue?

Continue