skip to content

A Vue 3 `<script setup>` profile form declares `let profile = reactive({ name: '', email: '' })`, then runs `profile = reactive(await loadProfile())`; why do the fields stay empty, and how do you fix it?

level: seniorimportance: should knowfreq 50%

answer

  1. variables are not observable
  2. subscribers sit on the first proxy
  3. mutate in place instead
  4. the reset object got mutated too

basics

~20 s

Reassigning the variable is invisible to Vue: the render tracked properties of the first proxy, and the new proxy has no subscribers. Mutate the existing object with Object.assign(profile, data), or hold the form in a ref and assign profile.value.

solid answer

~40 s

Vue tracks property reads on a specific proxy, not on a variable. The form's render effect read `profile.name` and `profile.email` through the first proxy, so it is subscribed to that object. `profile = reactive(...)` rebinds a local variable, which JavaScript gives Vue no way to observe, so nothing re-renders and the fields stay empty until something unrelated happens to re-render the component. Anything that captured the old proxy - a watcher source, a value given to `provide()`, a composable argument - keeps the old object for good. Fix it by mutating in place with `Object.assign(profile, data)`, or declare `const profile = ref({...})` and assign `profile.value = data`, which triggers every reader. For a reset, assign from a factory function, because writes through a proxy land on the original object.

code

vue · 23 lines
vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface Profile { name: string; email: string }
declare function loadProfile(): Promise<Profile>

const emptyProfile = (): Profile => ({ name: '', email: '' })
const profile = ref<Profile>(emptyProfile())

onMounted(async () => {
  profile.value = await loadProfile() // triggers every reader
})

function reset() {
  profile.value = emptyProfile()      // fresh object each time
}
</script>

<template>
  <input v-model="profile.name" />
  <input v-model="profile.email" />
  <button @click="reset">Reset</button>
</template>

go deeper

for a junior

Know the rule from the docs: keep the same reference to a reactive object; to replace state wholesale, use a ref and assign .value.

for a middle

Explain that subscriptions attach to properties of a specific proxy, so rebinding a variable notifies nobody, and show both fixes.

for a senior

Diagnose the intermittent version of the bug, find every consumer that captured the old proxy, and catch the reset trap where edits leak into the initial object.

for a principal

Use this bug to justify a convention: state that can be replaced lives in a ref, and let bindings of reactive() are flagged in review.

## The symptom A user-profile form in a `<script setup>` component: ```ts let profile = reactive({ name: '', email: '' }) onMounted(async () => { profile = reactive(await loadProfile()) }) ``` The request succeeds, the variable now holds a proxy of the loaded data, and the inputs bound with `v-model="profile.name"` still show empty strings. Nothing throws. (With `const`, the reassignment throws a `TypeError`, which is usually why someone changed the declaration to `let`.) Sometimes the data then appears later, apparently at random - which makes the bug look flaky. ## Why: tracking hangs off the proxy, not the variable - During the first render, the component's render effect read `profile.name` and `profile.email` **through the first proxy**. It is subscribed to those properties *of that object*. - `profile = ...` rebinds a local JavaScript variable. There is no hook for a variable assignment, so Vue sees nothing and triggers nothing. - The new proxy has **no subscribers**. Writes to it notify no one. - `<script setup>` exposes a `let` binding to the template through a getter, so the **next** render - caused by some unrelated state change - reads the new object and the form suddenly shows the loaded data. That is the "random" appearance. - Anything that captured the first proxy keeps it permanently: a `watch(profile, ...)` source, a value handed to `provide()`, a composable that stored its argument, an event handler closed over it elsewhere. The Vue guide lists this as one of the limitations of `reactive()`: you must always keep the same reference to a reactive object, because replacing it loses the reactive connection. ## Fix 1: mutate the existing object ```ts const profile = reactive({ name: '', email: '' }) onMounted(async () => { Object.assign(profile, await loadProfile()) }) ``` `Object.assign` writes each key through the proxy, so every subscriber is notified and the reference never changes. Two details matter: - Keys **missing** from the loaded data keep their old values. For a reload or reset, assign a complete object. - Keys the initial object did not declare are added; the proxy tracks them, but it is clearer to declare the full shape up front. ## Fix 2: hold the form in a ref ```ts const profile = ref({ name: '', email: '' }) onMounted(async () => { profile.value = await loadProfile() }) ``` The ref's `.value` setter triggers everything that read `profile.value`, including the template (which unwraps the top-level ref, so `v-model="profile.name"` is unchanged). The new object is made reactive automatically. This is the variant the docs steer you toward, and it is why whole-object replacement is the headline argument for `ref()`. ## The reset trap: writes land on the original object A reactive proxy forwards writes to its **target object**. That breaks a common reset pattern: ```ts const initial = { name: '', email: '' } const profile = reactive(initial) // the user types a name... Object.assign(profile, initial) // no visible reset ``` The typed name was written into `initial` itself, so assigning `initial` back changes nothing. Use a **factory** so every reset gets a fresh object: ```ts const emptyProfile = () => ({ name: '', email: '' }) const profile = reactive(emptyProfile()) const reset = () => Object.assign(profile, emptyProfile()) ``` ## Diagnosis checklist | Symptom | Likely cause | Fix | |---|---|---| | Loaded data never shows, then appears after an unrelated change | reactive variable reassigned | `Object.assign` or a ref | | `TypeError` on assignment | `const` reactive reassigned | same - do not reassign | | Reset button does nothing | initial object mutated through the proxy | factory function | | A watcher stops firing after a reload | it captured the old proxy | keep one reference, or watch a ref | When reviewing code, treat `let x = reactive(` as a smell: a reactive object that needs to be replaced should have been a ref.

  • Why does the loaded profile sometimes appear later with no code change in a Vue 3 `<script setup>` component?
    The compiler exposes a `let` binding to the template through a getter, so any later re-render reads the current variable, which now holds the new proxy. The reassignment itself triggered nothing; an unrelated state change that re-rendered the component made the data show up, and from then on the render tracks the new proxy.
  • With `Object.assign(profile, data)` on a Vue 3 reactive form, what happens to fields the server omitted?
    They keep whatever value they had, because `Object.assign` only writes the keys present in `data`. For a full reload or a reset, merge a complete object - for example `Object.assign(profile, emptyProfile(), data)` - so every declared field is overwritten.

saying these in an interview costs you the question

  • Reassigning a reactive variable is fine because Vue tracks the variable name.
  • Switching const to let is the correct fix for the TypeError on reassignment.
  • reactive() clones its argument, so the initial object stays pristine for resets.
  • Wrapping the new data in reactive() again is enough to reconnect the template.
  • Object.assign on a reactive object also loses reactivity because it replaces the object.