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?
answer
- variables are not observable
- subscribers sit on the first proxy
- mutate in place instead
- the reset object got mutated too
basics
~20 sReassigning 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 sVue 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<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
Know the rule from the docs: keep the same reference to a reactive object; to replace state wholesale, use a ref and assign .value.
Explain that subscriptions attach to properties of a specific proxy, so rebinding a variable notifies nobody, and show both fixes.
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.
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.