Skip to content
intermediate Phase 4 · Computed Properties and Watchers

Writable Computed Values

Define both a getter and a setter for two-way derived state.

35m
0 problems
Topic Progress 0%

Two Functions, One Ref: Tracing an Assignment Through get and set

From Computed Values you know the getter-only form: a lazy, cached, read-only ref. The writable form answers a different question — what if the derived value must also accept input? The canonical case is a full-name field backed by two separate sources:

const first = ref('Ada')
const last = ref('Lovelace')

const full = computed({
  get: () => `${first.value} ${last.value}`,
  set: (value) => {
    const [f = '', l = ''] = value.split(' ')
    first.value = f
    last.value = l
  }
})

Passing an object with get and set functions to computed() returns a ref you may both read and assign. The two directions travel different paths, and the asymmetry is the mechanism worth memorizing:

The read path is identical to a getter-only computed: a read of full.value runs the getter (first time, or after invalidation), registers dependencies — first, last — and caches the result.

The write path is new. full.value = 'Grace Hopper' does not call the getter and does not touch the cache directly. It invokes the setter with the assigned value. The setter's job is to translate the input back into the underlying sources: here it splits the string and writes first and last.

What happens next closes the loop: those writes mutate the very dependencies the getter tracks, so the computed is marked dirty. The next read — almost always the component re-rendering because first/last are rendered too — runs the getter, which now produces 'Grace Hopper' from the updated sources. The value you assigned and the value you can subsequently read agree because the setter kept the sources consistent with the derivation.

assign full.value ──▶ setter('Grace Hopper') ──▶ first='Grace', last='Hopper'
                                                        │
read full.value  ◀── getter re-runs (dirty) ◀── computed marked dirty

Three mechanical details that show up in reviews:

  • One setter can update several sources. The setter is an ordinary function; writing three refs from one input is normal and is the main reason this pattern exists.
  • The setter receives only the new value. Unlike a watch callback, there is no previous value argument. If your translation needs it, track it yourself or use a watcher.
  • The getter must stay a pure function of the sources. The setter changes sources; the getter merely projects them. If the setter also keeps a hidden variable the getter reads, you have built state outside the reactivity contract.

A second example, a temperature converter, shows the same skeleton with different arithmetic:

const celsius = ref(20)
const fahrenheit = computed({
  get: () => (celsius.value * 9) / 5 + 32,
  set: (value) => { celsius.value = ((value - 32) * 5) / 9 }
})

The TypeScript surface distinguishes the two forms on purpose: a getter-only computed types as ComputedRef<T> while the get/set form types as WritableComputedRef<T>, so assigning to a read-only computed is a compile error under vue-tsc before Vue ever warns at runtime — and a v-model wired through a setter-less computed fails the build instead of shipping a silently frozen field. The same type exposes why v-model internals are shaped the way they are: defineModel's generated value is a WritableComputedRef precisely because it bridges a prop and an emit. And when a derived value should stay editable in one place but frozen everywhere else, readonly() over the writable computed wraps it in a proxy whose dev-mode writes warn — a clean way for a composable to hand child code something it must never write.

Two inputs, one source of truth, no duplicated state to synchronize. This is the interview framing that lands: a writable computed is not two-way state — it is one-way state with a translating adapter on the write side. The source stays authoritative; the computed is a lens. When the round-trip is not perfectly invertible, that lens starts distorting, which is where the next two topics go.

Where Writable Computeds Earn Their Keep: Forms, Units, and Prop Bridges

The get/set skeleton earns its complexity in three recurring production situations. Each one is a variation on "the UI needs one shape, the state needs another."

1. Splitting one input into structured state. The full-name computed from the previous chapter is the minimal case; a shipping address form is the realistic one. A single "contact" field in a modal edits three refs:

const name = ref('')
const email = ref('')
const phone = ref('')
const contact = computed({
  get: () => ({ name: name.value, email: email.value, phone: phone.value }),
  set: ({ name: n, email: e, phone: p }) => {
    name.value = n; email.value = e; phone.value = p
  }
})

Any component or test that hands over a whole contact object drives the individual fields through one setter, while each field keeps its own binding and validation.

2. Unit and format normalization. Store money as integer cents, show decimal units; store meters, show centimeters; store an ISO date string, show a localized one:

const priceCents = ref(1999)
const priceInput = computed({
  get: () => (priceCents.value / 100).toFixed(2),
  set: (text) => {
    const n = Number.parseFloat(text)
    if (!Number.isNaN(n)) priceCents.value = Math.round(n * 100)
  }
})

The guard in the setter matters: <input type="number"> delivers an empty string while the user clears the field, and Number.parseFloat('') is NaN. Writing NaN into the source would poison every downstream calculation, so the setter validates and refuses. Empty input simply leaves the previous valid state in place.

3. Bridging a prop into local, editable state. A child receives value as a prop (which is read-only — see One-Way Data Flow Rules) but must let the user edit it. A writable computed lets the child keep the parent's data authoritative while translating edits into events:

const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

const text = computed({
  get: () => props.modelValue,
  set: (v) => emit('update:modelValue', v)   // never writes the prop directly
})

This is the hand-written engine underneath v-model on custom components — the v-model topic replaces the boilerplate with the defineModel macro, but the mechanics you are reading now are exactly what the macro emits.

One trade-off to state plainly: a writable computed that merely forwards to an event adds ceremony compared to a plain input with :value plus @input. Reach for it when the child needs to read the derived value in its own template or logic — a character counter, a validity badge, a formatted preview. If the value never needs deriving inside the child, the plain prop-plus-event pair is smaller and clearer.

Vue 3.4's defineModel macro is this third pattern compiled for you. The hand-written version:

const props = defineProps<{ modelValue: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
const qty = computed({
  get: () => props.modelValue,
  set: (v) => emit('update:modelValue', v)
})

and const qty = defineModel<number>() produce the same contract: a writable computed bridging a read-only prop and an event. Knowing the expansion is interview gold, because it answers the two follow-up questions mechanically: why the parent must stay the authority — the setter only requests a change — and why a :model-value binding with no @update:model-value listener makes a control feel frozen: the setter emits into the void and the getter re-derives the unchanged prop.

A related review point: keep normalization in one place. If set rounds cents and a submit handler also rounds, the two rules drift apart and the user sees values jump between typing and saving. The setter is the normalization boundary; everything downstream trusts it.

When the Round-Trip Fights Back: Missing Setters and Non-Invertible Derivations

A writable computed quietly depends on one assumption: get(set(x)) should give back something close to x. When that fails, the UI develops a personality — values snap away mid-typing, or assignments vanish with nothing but a console warning. Two failure families cover almost every report.

The missing setter. With a getter-only computed, assignment is refused: development builds warn that the computed is readonly and the write is dropped. The user-visible version of this is a v-model bound to such a computed — the field echoes keystrokes from its own internal state, the parent's value never changes, and nothing errors. The fix is either to add a set (if the value is genuinely meant to be editable) or to stop binding it two-way (if it is not). A derived total is not an input; binding v-model to computed(() => cartTotal.value) is a design error, not a missing setter.

The non-invertible setter. Consider rounding in the setter:

const amount = ref(10)
const amountInput = computed({
  get: () => amount.value.toFixed(2),        // '10.00'
  set: (text) => { amount.value = Math.round(Number(text) * 100) / 100 }
})

The user types 10.9 in a controlled input; the setter stores 10.9, the getter re-renders 10.90 — harmless. Now type 10.999: the setter rounds to 11, the getter shows 11.00, and the digit the user typed disappears under their fingers. Worse variants are setters that always "correct" input — trimming on every keystroke, forcing a prefix, re-parsing through a lossy format — because each correction propagates back into the input as a new value.

The underlying trap: each setter write invalidates the computed, and each invalidation re-derives the value the input is displaying. If derivation is not the inverse of what the user typed, the display and the keystrokes take turns overwriting each other. The escape is to decide when normalization happens and stop normalizing on every keystroke:

// normalize once, on commit — not on every input event
<input :value="amountInput" @change="amountInput = $event.target.value" />

Formatting belongs on blur or submit; while the field has focus, the user's text wins.

Loops. The pathological end of the same spectrum is a setter whose writes keep re-triggering themselves:

// BROKEN — each write changes a dependency, re-deriving a new value, writing again
const double = computed({
  get: () => count.value * 2,
  set: (v) => { count.value = v / 2 + 1 }   // never settles when count is also watched/rendered
})

If your setup ends in Maximum recursive updates exceeded, inspect every setter that touches state the render also reads and confirm the chain reaches a fixed point after one pass. A setter should translate and stop — side effects, persistence, and cascading updates belong in a watch, where you control timing and can run cleanup.

The "normalize on commit" fix has a concrete shape — split the raw text from the normalized value so typing never round-trips:

const cents = ref(1999)
const raw = ref('19.99')              // exactly what the user typed
const normalized = computed({
  get: () => raw.value,
  set: (text) => {                    // runs once, on commit
    const n = Number.parseFloat(text)
    if (!Number.isNaN(n)) cents.value = Math.round(n * 100)
  }
})
<input :value="raw" @input="raw = $event.target.value" @change="normalized = raw" />

Typing writes the raw text the getter echoes verbatim — no rounding, no fighting the caret — and only @change (blur or Enter) routes through the rounding setter. The pattern is version-stable because it follows from the mechanism itself: a controlled input renders whatever the derived value says, so the derivation must never disagree with in-progress keystrokes.

The default should stay read-only. Every setter is an invitation to write, and every write needs the guard rails above. Most derived values in an app — totals, labels, filtered lists, formatted dates — are projections with nothing to project back. Omit the setter: the dev warning then acts as a tripwire catching accidental two-way bindings before they ship. Reach for get/set deliberately, in the three situations from the previous chapter, and keep the derivation as close to invertible as the problem allows.

Quiz

1. With `const full = computed({ get, set })`, what happens when you assign `full.value = 'Grace Hopper'`?

Question 1 options

2. A money field stores integer cents in state. Which setter is correct for a writable computed shown as decimal units?

Question 2 options

3. A user types '10.999' into a controlled input whose writable computed's setter does Math.round. The input then displays '11.00' mid-typing. What is the mechanism?

Question 3 options

4. You bind v-model to a getter-only computed and the field accepts keystrokes but the value never changes, with a dev warning about a readonly computed. What is the right resolution?

Question 4 options

Flashcards

Question

What does computed({ get, set }) return, and how does an assignment to it flow?

Answer

A writable ref-like value. Assigning .value invokes only the setter with the new value; the setter updates the underlying sources; those writes mark the computed dirty; the getter re-runs at the next read. No getter call happens during the assignment itself, and the setter gets no previous value.

Question

Name the three production situations where a writable computed is the right tool.

Answer

1) Splitting one input into several backing refs (full name → first/last, contact object → fields). 2) Unit/format normalization at a boundary (integer cents ↔ decimal display, ISO date ↔ localized). 3) Bridging a read-only prop into editable state by emitting in the setter — the hand-written engine under v-model/defineModel.

Question

Why can a writable computed fight the user's keystrokes, and how do you stop it?

Answer

Because each setter write invalidates the computed and the re-derived value is fed back into the controlled input — when get∘set is not the identity (rounding, trimming, forced prefixes), display and typing overwrite each other. Normalize on commit (@change/blur/submit) instead of on every input event, and keep normalization in exactly one place.

Question

What happens when you assign to a getter-only computed, and what should you do about it?

Answer

Dev builds warn the computed is readonly and the write is dropped — including via v-model, where the field appears to work but never changes the source. Decide deliberately: give it a set if the value is genuinely editable, or remove the two-way binding if it's a pure derivation (totals, labels, filtered lists).

Question

What makes a writable computed's setter loop, and where do side effects belong instead?

Answer

A setter whose writes keep re-triggering derivation without reaching a fixed point (e.g., set adding +1 to a source the getter/render depends on) ends in 'Maximum recursive updates exceeded'. The setter should translate and stop; persistence, logging, and cascading updates belong in a watch callback where timing and cleanup are under your control.

Revision Notes

Key Takeaways

  • 1. computed({ get, set }) returns a writable ref: reads go through the cached getter, assignments go through the setter only
  • 2. The setter's writes mutate the getter's sources, which invalidates the computed — the assigned value becomes visible at the next read
  • 3. Writable computeds fit three shapes: splitting one input into many refs, normalizing units/formats at a boundary, and bridging read-only props via emits
  • 4. When get∘set is not the identity, controlled inputs fight the user — normalize on commit, keep normalization in one place
  • 5. Default to getter-only; the readonly dev warning is a feature that catches mistaken two-way bindings on pure derivations

Interview Tips

  • • "Implement two-way full-name binding with computed" — write get/set over first/last refs, then explain the cycle: assignment invokes the setter, the setter's source writes dirty the computed, the getter re-derives on next read
  • • "Why does my controlled input reset what I type?" — the setter normalizes every keystroke and the re-derived value is pushed back into the field; move normalization to blur/@change
  • • "v-model on a computed does nothing — why?" — getter-only computeds are readonly; v-model needs a setter, so either add get/set or remove the two-way binding if the value is a pure derivation
  • • "How does v-model on a custom component actually work?" — a writable computed over props.modelValue that emits update:modelValue in its setter is the hand-written version; defineModel collapses exactly that pair

Cheat Sheet

Writable Computed

Shape:

const full = computed({
  get: () => `${first.value} ${last.value}`,
  set: (v) => { const [f = '', l = ''] = v.split(' '); first.value = f; last.value = l }
})
full.value = 'Grace Hopper'   // setter → sources updated → dirty → getter re-runs on next read

Write cycle: assign → setter(newValue) only (no getter, no oldValue arg) → sources mutate → computed dirty → next read re-derives.

Use it for: one input → many refs · unit/format boundary (cents ↔ decimal, ISO ↔ localized) · prop+emit bridge (the engine under defineModel).

Keep it read-only when: the value is a pure projection (total, label, filtered list) — the dev 'readonly' warning is a tripwire against accidental v-model bindings.

Failure modes:

Symptom Cause Fix
value never changes + readonly warning no setter (v-model on read-only computed) add set or drop two-way binding
typed digit vanishes mid-edit setter normalizes (rounds/trims) every keystroke; get∘set ≠ identity normalize on @change/blur, not @input
'Maximum recursive updates exceeded' setter writes state the getter/render re-reads, never settles setter translates only; side effects → watch
NaN poisons totals number input delivered '' → parseFloat NaN guard in setter before writing

Rule: source stays authoritative; the writable computed is a translating lens, not second state.

Continue Learning

Before You Start

Related Reading