A Vue 3 `useCounter(start)` composable is called as `useCounter(props.start)`, and later changes to that prop are ignored - why, and how should it accept the argument?
answer
- the argument is evaluated at the call
- accept a value, ref or getter
- normalise inside a tracked read
- toValue, not toValue at the top
basics
~20 sprops.start is evaluated at the call site, so the composable receives a plain number with no link to the prop. Type the parameter MaybeRefOrGetter<number>, have callers pass () => props.start or a ref, and read it with toValue() inside a watch getter, computed or watchEffect.
solid answer
~40 sJavaScript evaluates `props.start` before the call, so `useCounter` receives the number the prop had at setup time; nothing inside it can re-read the prop. The fix has two halves. The **caller** passes something that can be re-read: a getter `() => props.start`, `toRef(() => props.start)`, or an existing ref. The **composable** accepts any of them by typing the parameter `MaybeRefOrGetter<number>` and normalising with `toValue(start)`, which returns a ref's `.value`, calls a getter, or returns a plain value. The crucial detail is *where* `toValue()` runs: inside a `watch` getter, `computed` or `watchEffect`, so the underlying prop read is tracked. Calling it once at the top of the composable recreates the original bug. Decide deliberately whether the argument is an initial value (read once) or a live input (watched).
code
ts · 13 linesimport { computed, toValue, type MaybeRefOrGetter } from 'vue'
// Live input: re-reads the source inside a tracked computed
export function useDiscountedPrice(
price: MaybeRefOrGetter<number>,
rate: MaybeRefOrGetter<number>,
) {
return computed(() => toValue(price) * (1 - toValue(rate)))
}
// Caller in <script setup>:
// const props = defineProps<{ price: number; rate: number }>()
// const total = useDiscountedPrice(() => props.price, () => props.rate)go deeper
Recall that passing props.start passes a number, and that a getter like () => props.start lets the function re-read the prop.
Explain MaybeRefOrGetter and toValue(), and why toValue must run inside a watch getter, computed or watchEffect to be tracked.
Diagnose 'ignores prop changes' reports by finding snapshot arguments and top-level toValue calls, and fix both caller and composable.
Set a convention distinguishing initial-value parameters from live inputs, so composable signatures state which contract they offer.
## The bug ```ts // useCounter.ts export function useCounter(start: number) { const count = ref(start) const reset = () => (count.value = start) return { count, reset } } // Component.vue const props = defineProps<{ start: number }>() const { count, reset } = useCounter(props.start) ``` `props.start` is an expression, and JavaScript evaluates arguments **before** calling a function. The read happens once, during setup, outside any tracked effect. `useCounter` gets the number `10` (say); when the parent later passes `20`, `reset()` still resets to `10`. The docs make the same point about destructuring: passing a property of reactive state into a function hands over a plain value and loses the reactivity connection. ## Step 1: let callers pass something re-readable The caller has three live options: | Argument | What it is | Notes | |---|---|---| | `() => props.start` | a getter | the docs' recommended form; cheapest | | `toRef(() => props.start)` | a readonly ref that calls the getter on each `.value` read | 3.3+; useful when an API insists on a ref | | `toRef(props, 'start')` | a property ref linked to the prop | writing it is a prop mutation, which is not allowed | Passing `ref(props.start)` does **not** help: it creates a new ref initialised from the snapshot. ## Step 2: accept all forms with `MaybeRefOrGetter` and `toValue()` Since Vue 3.3, `vue` exports the type `MaybeRefOrGetter<T>` (a plain `T`, a ref or a getter) and the helper `toValue()`: - `toValue(ref)` returns `ref.value`; - `toValue(getter)` calls the getter and returns its result; - `toValue(value)` returns the value unchanged. ```ts import { ref, toValue, watch, type MaybeRefOrGetter } from 'vue' export function useCounter(start: MaybeRefOrGetter<number>) { const count = ref(toValue(start)) watch(() => toValue(start), (next) => { count.value = next }) const reset = () => (count.value = toValue(start)) return { count, reset } } ``` ## Step 3: call `toValue()` where the read is tracked `toValue()` does not create a subscription by itself; it simply reads. The read is tracked only when it happens inside an effect: 1. `ref(toValue(start))` - reads once to seed the counter. Intended. 2. `watch(() => toValue(start), ...)` - the watch getter runs inside a tracked effect, so the prop read inside `() => props.start` is tracked and later changes fire the callback. 3. `reset` - reads the current value at the moment it is called. No tracking needed. The classic regression is writing `const initial = toValue(start)` at the top and using `initial` everywhere: the composable now accepts refs and getters in its signature, but still behaves like a snapshot. ## Is the argument an initial value or a live input? Not every argument should follow the prop. A counter's start may intentionally seed the state once and then let the user take over. Say which it is in the name and type: - **Initial value**: name it `initial`, type it `number`, read it once. Callers passing `props.start` are then correct. - **Live input**: type it `MaybeRefOrGetter<number>` and read it with `toValue()` inside a watcher or computed. Mixing the two - a live-looking parameter read once - is what produces the "ignores prop changes" bug report. ## Diagnosing it in an existing codebase - Search for composable calls with `props.x` or a destructured prop as a bare argument. - Inside composables, look for `toValue()` or `unref()` calls at the top level rather than inside `watch`, `computed` or `watchEffect`. - Reproduce by changing the prop in a test and asserting that the composable's output follows. ## Summary Arguments are evaluated at the call, so passing `props.start` passes a number. Pass a getter, accept `MaybeRefOrGetter`, and normalise with `toValue()` inside a tracked function. ## Proving the fix with a test A regression test states the contract directly. Mount the component with `start: 10`, change the prop to `20`, wait for the update, and assert that the composable's output (the counter, or the result of `reset()`) now uses `20`. Under the original code the assertion fails; with a getter argument and `toValue()` inside `watch`, it passes. A second test with a plain number argument documents that the composable still accepts values, which keeps the signature honest about its three accepted shapes.
- What is the difference between Vue's `toValue()` and `unref()`?Both return `.value` for a ref and pass plain values through. `toValue()`, added in 3.3, also calls a getter and returns its result, which is what lets a composable accept `() => props.x`. `unref()` would return the getter function itself.
- Why not have the composable accept the whole `props` object instead of a getter?It works - `props.start` read inside a watcher is tracked - but it couples the composable to one component's prop names and makes it unusable with plain refs or computed values. A `MaybeRefOrGetter` parameter keeps it independent of where the value comes from.
saying these in an interview costs you the question
- Passing props.start keeps the composable linked to the prop.
- Wrapping the argument as ref(props.start) keeps it in sync with the prop.
- Calling toValue() once at the top of the composable is enough.
- toValue() subscribes to the source wherever it is called.
- Every composable argument should be live; an initial value is always a bug.