skip to content

In Vue 3, why must an Object or Array prop's default be a factory function, and when exactly is that default applied?

level: middleimportance: should knowfreq 55%

answer

  1. one definition, many instances
  2. shared reference problem
  3. called once per instance
  4. undefined triggers it, null does not
  5. Function type is the exception

basics

~20 s

A prop's options are defined once per component, so a literal object or array default would be one shared reference that every instance could mutate. A factory returns a fresh value per instance and runs only when the resolved value is undefined.

solid answer

~40 s

The `props` declaration belongs to the component definition, not to each instance. If you write `default: { name: 'Guest' }`, every `<UserCard>` that falls back to the default receives the **same** object, so mutating it in one card changes all of them. Writing `default: () => ({ name: 'Guest' })` makes Vue call the factory per instance, cache the result for that instance, and pass the raw props as its argument. The default applies when the resolved value is `undefined`: the prop was omitted or the parent passed `undefined` explicitly. `null` counts as a real value and does not trigger it. Primitives can be literal defaults, and a prop with `type: Function` treats a function default as the value itself, not as a factory.

code

vue · 16 lines
vue
<script setup>
const props = defineProps({
  user: {
    type: Object,
    default: () => ({ name: 'Guest', tags: [] })
  },
  initials: {
    type: String,
    default: (raw) => (raw.user?.name ?? 'Guest').slice(0, 1)
  }
})
</script>

<template>
  <div class="user-card">{{ props.initials }} — {{ props.user.name }}</div>
</template>

go deeper

for a junior

Remember that object and array defaults are written as functions returning a new value.

for a middle

Explain the shared-reference cause, the undefined-only trigger, per-instance caching, and the Function-type exception.

for a senior

Spot null-versus-undefined bugs from API data and state leaking between instances through a shared default.

for a principal

Decide where defaults live in a design system, in the child or normalised by callers, and how that shapes null handling across teams.

## The shared-reference problem A Vue 3 component's props declaration is part of its **definition**. The object you pass to `defineProps()` (or the Options API `props` option) is created once when the module loads and reused by every instance of that component. If a default is a literal object, that literal is also created once: ```js // risky: one object shared by every UserCard defineProps({ user: { type: Object, default: { name: 'Guest', tags: [] } } }) ``` Every `<UserCard>` that renders without a `user` receives a reference to the same object. If any card pushes into `user.tags` or renames the guest, every other defaulted card sees the change. Vue 3's runtime does not warn about this; it simply uses the value you gave it. ## How the factory form works When `default` is a function and the prop's `type` is not `Function`, Vue treats it as a **factory**: 1. It is called only when the resolved value is `undefined`. 2. It is called **once per instance**; the result is cached for that instance, so later updates that still resolve to `undefined` reuse it. 3. It receives the **raw props** object as its argument, so a default can depend on another prop. 4. It runs while props are being resolved, before the component's setup code, so it cannot reach local state. ```js defineProps({ user: { type: Object, default: () => ({ name: 'Guest', tags: [] }) }, roles: { type: Array, default: () => [] }, size: { type: String, default: 'md' } }) ``` The arrow function wraps the object literal in parentheses; without them the braces are read as a function body and the factory returns `undefined`. ## When the default applies | Parent passes | Resolved value | Default used? | |---|---|---| | nothing | `undefined` | yes | | `:user="undefined"` | `undefined` | yes | | `:user="null"` | `null` | no | | `:user="currentUser"` | the object | no | The `null` row matters in practice: an API that returns `null` for "no user" bypasses your default, and code that reads `props.user.name` then throws. Either normalise to `undefined` in the parent, or handle `null` in the child, for example with a `computed` that falls back. ## The Function-type exception For `type: Function`, a function default is the **value**, not a factory: ```js defineProps({ formatName: { type: Function, default: (u) => u.name } }) ``` Here `props.formatName` is that arrow function itself. Vue skips the factory call precisely because the declared type says the prop is a function. ## Primitives and immutable values - Strings, numbers, booleans and `null` are safe as literal defaults, because they cannot be mutated in place. - A frozen object is safe to share, but a factory is clearer and costs little. - Boolean props have their own rule: an absent Boolean without a default is cast to `false`. ## Where defaults are declared In the runtime form, the `default` field is the only place. With type-based declarations, defaults are supplied through `withDefaults` or 3.5 destructure defaults, which have their own mutable-default rules and are covered with TypeScript and destructuring. The per-instance, `undefined`-only semantics described here are what the runtime applies in every case, because those forms compile down to the same `default` option. ## Checklist - Object or array default: always a factory. - Remember `null` is a value and skips the default. - Do not reach for setup state inside a factory; use the raw-props argument instead. - For a function-typed prop, the default is the function you want to call later. ## Proving the bug in a test The shared-default problem is easy to demonstrate, which is why interviewers like it: 1. Declare `user` with a literal object default. 2. Render two `<UserCard>` instances without a `user` prop. 3. In the first card, push a tag into `props.user.tags`. 4. Read the second card's `props.user.tags`: it contains the tag too, because both cards hold one object. Switch the default to `() => ({ name: 'Guest', tags: [] })` and the second card's list stays empty, since each instance called the factory for itself. The same test also shows that the factory runs once per instance, not on every render: the cached object keeps its identity across updates.

  • In Vue 3, can a prop default factory read another prop's value?
    Yes. The factory receives the raw props object as its argument, so `default: (raw) => raw.user?.name ?? 'Guest'` works. It runs during prop resolution, before setup code, so it cannot use refs or functions declared in `<script setup>`; pass anything it needs in as a prop.
  • A Vue 3 parent passes `:user="null"` to a card whose `user` prop has a factory default. What does the child get?
    `null`. Defaults apply only when the resolved value is `undefined`, whether omitted or passed explicitly. `null` is a real value, so the factory is not called, and a template reading `user.name` throws. Normalise `null` to `undefined` in the parent or guard in a `computed`.

saying these in an interview costs you the question

  • Believes Vue clones a literal object default for each instance
  • Expects a null prop value to fall back to the default
  • Thinks Vue throws or warns on a literal object default
  • Assumes a function default on a Function-typed prop is called
  • Writes () => { name: 'Guest' } and expects an object back