Skip to content
intermediate Phase 5 · Components, Props, and Events

One-Way Data Flow Rules

Keep props read-only and move mutations back up through events.

40m
0 problems
Topic Progress 0%

The Parent Owns the Data: What the Readonly Warning Is Protecting

A Counter component receives count as a prop and its plus button wants to increment. The tempting one-liner fails loudly in development:

<script setup>
const props = defineProps<{ count: number }>()
</script>
<template>
  <button @click="props.count++">+</button>   <!-- illegal -->
</template>
[Vue warn] Set operation on key "count" failed: target is readonly.

Props are a read-only view of the parent's state. The parent decided the value, the parent's render depends on the value, and often several siblings read the same value — a child that could write it would turn one authoritative state into N competing writers. The warning is not a limitation to route around; it is the boundary that makes every other guarantee in the component model hold.

The guarantees you get by keeping the flow one-way are concrete:

  • One writer per piece of state. When count changes, there is exactly one place to look — the parent's code. Devtools shows the mutation's origin; undo/replay systems record one stream; tests set the parent's state and assert the whole subtree.
  • Derivations stay consistent. If the parent computes doubled = computed(() => count.value * 2) and the header also renders count, a rogue child-side write would update some readers and not others, depending on who tracks what. With one owner, every reader re-renders from the same change.
  • The prop chain stays debuggable. Any value in any component can be traced upward to its owner. Bidirectional prop mutation makes that trace a loop with no entry point.

The legal ways for the child to request change, in increasing order of ceremony:

// 1. Emit a semantic event — the parent decides what it means
const emit = defineEmits<{ increment: []; reset: [] }>()

// 2. Emit a value-change request — pairs with the prop for v-model-style binding
const emit = defineEmits<{ 'update:count': [value: number] }>()
emit('update:count', props.count + 1)

Both keep the write in the parent's handler: @increment="count++" or @update:count="count = $event". The difference is contract shape — increment is a command the parent may interpret (maybe it increments, maybe it logs, maybe it's disabled by a permission check), while update:count is a set request that pairs mechanically with the prop. Choosing the shape that matches the relationship is the design work; the mechanism is the same.

One enforcement detail matters more than it looks: the readonly wrapper exists only in development builds. In production Vue skips the shallowReadonly wrapper around props, so props.count++ neither warns nor throws — the assignment may appear to work, silently corrupting a render-dependent value, until the next parent update overwrites it with the authoritative one. That inverts the usual debugging expectation ("works in prod, breaks in dev") and is why the discipline cannot depend on the warning: never assign, always emit through the channel built in Emitting Events to Parents.

The practical debug recipe when a value "changes itself": search for every writer of that field. If a child template assigns into props.x, or calls a mutating method on a prop object, the ownership violation is the bug — with or without a console warning to point at it.

A related anti-pattern deserves its own warning: the local mirror. const local = ref(props.count) copies the prop once and never follows it, so when the parent's state changes from elsewhere the child shows stale data. If a child truly needs to edit a prop-derived value — a text field normalizing what it displays — the correct bridge is the writable computed that emits in its setter, from Writable Computed Values: one source of truth, a translating adapter, no second state. Assignment is out, mirroring is a bug, emission is the contract — and the trickiest violations of this rule don't look like assignment at all. They mutate the inside of what the prop delivered.

The Silent Violation: Mutating the Inside of a Prop Object

The readonly enforcement on props is shallow. Assigning to the prop binding warns; mutating a property of an object received through a prop does not warn, does not error, and visibly works — while quietly breaking encapsulation in ways that surface weeks later.

<!-- child: TodoItem.vue -->
<script setup>
const props = defineProps<{ todo: { id: number; text: string; done: boolean } }>()
</script>
<template>
  <!-- no warning anywhere; the parent's todo flips -->
  <input type="checkbox" :checked="props.todo.done"
         @change="props.todo.done = !props.todo.done" />
</template>

It works. That is the trap. props.todo is a reference to the same reactive object the parent holds — shallow readonly blocks writes to props.todo = ... but not to props.todo.done. The mutation lands directly in parent state, performed by code the parent never sees, and the costs are exactly the ones one-way flow exists to prevent:

  • Two writers, one state. The parent's own logic (an toggleAll, an undo snapshot, a server-sync reducer) and the child's checkbox now both write todo.done. Whichever order they run in, one of them is surprised.
  • The child became untestable in isolation. Its unit test passes a fixture object, clicks the checkbox, and asserts on the fixture — but the component's real contract is "emits a toggle event", which the test never observed. The fixture-mutation test keeps passing after the parent's logic breaks.
  • Reuse silently inherits the coupling. Drop TodoItem into a second list with a different data source and it mutates that source directly too — no compile error, no warning, no sign until a bug report.

The subtle variant appears one level up, through shared item objects inside derived arrays:

// parent
const doneTodos = computed(() => todos.value.filter(t => t.done))
<TodoList :todos="doneTodos" />

The child receives a new array — so list.shift() on the prop array is safely impossible — but every element is still the parent's original todo object. A child that mutates item.text = item.text.trim() reaches through the filtered view into the shared source, affecting the unfiltered list too. Derived arrays copy references, not data; the readonly boundary is the prop binding, not the object graph behind it.

Arrays deserve their own line because the shallow boundary behaves differently for them. props.list.push(item) and props.list.splice(0, 1) on an array prop are silent parent-state mutations exactly like todo.done = ... — the readonly proxy blocks reassignment of the binding, not methods called on the array the parent owns. A child that "adds a tag" this way has rewritten the parent's list with no event, no warning, and no record:

<!-- child: silently mutates the parent's tags array -->
<button @click="props.tags.push(newTag)">add</button>
// the contract instead: intent up, mutation in the owner
const emit = defineEmits<{ addTag: [tag: string] }>()

The one array behavior that is structurally blocked is any operation requiring reassignment of the prop binding itself — but arrays arrive as reactive references, so everything short of reassignment passes through. The rule generalizes: any object with mutating methods is a mutation surface — arrays, Maps, Sets, class instances. Treat the prop's entire object graph as the parent's property and send intent.

The refactor is mechanical once you accept the ownership model:

<!-- child emits intent instead of mutating -->
<input type="checkbox" :checked="todo.done"
       @change="emit('toggle', todo.id)" />
const emit = defineEmits<{ toggle: [id: number] }>()
<!-- parent: the one place state changes -->
<TodoItem v-for="t in todos" :key="t.id" :todo="t" @toggle="toggleTodo" />
function toggleTodo(id) {
  const t = todos.value.find(t => t.id === id)
  if (t) t.done = !t.done
}

Now the child is a pure view of data plus a source of events; the parent's single writer can snapshot for undo, batch for the server, and be tested by calling toggleTodo directly. The event's payload carries identity, not mutation — which is the essence of the pattern the next chapter formalizes into the round trip, including the attribute-name bridge that lets user-name="Ann" arrive as the userName prop.

The Complete Round Trip: kebab-case Down, Events Up, State in the Parent

One-way data flow is not one direction of interaction — it is one direction of ownership. Data moves down as props, intent moves up as events, and the loop closes in the parent's handler. The full cycle, on a quantity stepper:

<!-- parent: owns the state -->
<script setup>
const quantity = ref(1)
</script>
<template>
  <QuantityStepper
    :current-quantity="quantity"
    :max="10"
    @update:quantity="quantity = $event"
  />
</template>
<!-- child: renders and reports, writes nothing -->
<script setup>
const props = defineProps<{ currentQuantity: number; max: number }>()
const emit = defineEmits<{ 'update:quantity': [value: number] }>()

function bump(delta: number) {
  const next = Math.min(props.max, Math.max(0, props.currentQuantity + delta))
  if (next !== props.currentQuantity) emit('update:quantity', next)
}
</script>
<template>
  <div class="stepper">
    <button @click="bump(-1)" :disabled="currentQuantity <= 0">−</button>
    <span>{{ currentQuantity }}</span>
    <button @click="bump(1)" :disabled="currentQuantity >= max">+</button>
  </div>
</template>

Trace it once carefully, because two name mappings and one reactivity rule make the loop work.

Down: kebab-case attributes become camelCase props. The parent writes :current-quantity="quantity"; the SFC compiler maps the attribute to the declared currentQuantity prop automatically. This is the same case transformation as events, and it exists because HTML attributes are conventionally kebab-case while JavaScript identifiers are camelCase. In-DOM templates get the kebab spelling for free (it is the only one that survives HTML parsing); SFC templates accept both. A mismatch here — parent writes :currentquantity — is a classic "prop is undefined" bug: the attribute sails past the declaration into $attrs instead.

Up: the emit lands in the matching listener with the payload. emit('update:quantity', 2) invokes the parent's @update:quantity handler with 2 as $event, which assigns quantity.value = 2 — in the parent's scope, on the parent's ref.

Close: the reactivity loop re-renders both sides. The parent's ref changes, the prop binding re-evaluates, the child re-renders with the new currentQuantity — no manual synchronization, no "refresh" method, no watch anywhere. The state changed exactly once, in one place, and the framework moved the news.

Two failure modes complete the picture, and both are cheap to test for.

The missing listener. Binding <QuantityStepper :current-quantity="quantity" /> without @update:quantity gives you a one-way display: clicks emit into nothing, the prop never changes, and the UI looks frozen. No compiler catches this — the prop is valid and the listener is optional — so the round trip must be verified by hand or by test.

The child's contract is its emissions, not its rendering. That is exactly what makes the one-way design testable in isolation:

// vitest + @vue/test-utils
const wrapper = mount(QuantityStepper, {
  props: { currentQuantity: 1, max: 10 }
})
await wrapper.findAll('button')[1].trigger('click')

expect(wrapper.emitted('update:quantity')[0]).toEqual([2])
expect(wrapper.props('currentQuantity')).toBe(1)   // the child never wrote the prop

Asserting the emission — not the displayed number — keeps the test about the child's contract, so the parent's formatting, currency, or layout changes cannot break it. When an interviewer asks how you unit-test a controlled component, this snippet is the answer: mount with props, drive the UI, assert emitted().

Note what the child didn't do: it never stored quantity, never watched the prop, and clamping logic lives where the constraint's owner put it — either side may clamp, but only the parent writes. When this exact pair is mechanical — set-request events named update:x paired with an x prop — v-model on a component collapses it into one attribute, and its defineModel macro collapses the child's boilerplate too. The sugar is worth using precisely because the round-trip discipline underneath is not optional: whoever owns the state, keeps owning it.

Quiz

1. A child template runs @click="props.count++" where count is a declared prop. What happens, and why does the rule exist?

Question 1 options

2. A child runs props.todo.done = !props.todo.done on a prop object. What is the behavior, and why is it worse than an assignment warning?

Question 2 options

3. A parent renders <user-card user-name="Ann" /> and the child declares a prop userName. What connects them?

Question 3 options

4. The parent passes a filtered computed array; the child mutates an item object received through that array. What happens?

Question 4 options

Flashcards

Question

State the one-way data flow contract in Vue components.

Answer

Data flows down as props (read-only), intent flows up as events, and the parent's handler is the only place state changes. Assignment to a prop warns and is dropped in dev; the child never becomes a second writer of the parent's data.

Question

Why does mutating a nested object from a prop go unnoticed, and what breaks?

Answer

Props are shallowly readonly: the binding can't be reassigned (warns), but properties of received objects can be written — silently mutating the parent's state. Consequences: two hidden writers fighting, undo/batching logic bypassed, isolated component tests asserting on fixtures instead of events, and reuse that inherits the coupling with no error anywhere.

Question

A parent passes a filtered computed array and the child mutates an item — why does it affect the source?

Answer

filter() creates a new array of the same object references. The child can't mutate the array itself, but element writes (item.text = ...) hit the shared objects and appear in the unfiltered source too. Derived arrays copy shape, not identity of data — children must emit intent (ids/values), never mutate items.

Question

Describe the full prop/event round trip for a quantity stepper.

Answer

Parent owns quantity ref; binds :current-quantity and listens @update:quantity="quantity = $event" (kebab attribute → camelCase prop via compiler). Child clamps and emits 'update:quantity' with the next value, never storing state. The parent's assignment mutates its ref, the prop re-evaluates, the child re-renders — one writer, zero manual sync.

Question

When does a child need a local copy of a prop, and what is the correct mechanism?

Answer

Only when it must edit a normalized view of the prop (text field reformatting, etc.). The mirror anti-pattern is const local = ref(props.x) — a snapshot that drifts from the parent. The correct bridge is a writable computed over the prop whose setter emits update:x — one source of truth, a translating adapter, no second state.

Revision Notes

Key Takeaways

  • 1. Props are a read-only view of parent state: assignment warns and is dropped, so change requests travel up as events and land in the parent's handler
  • 2. The readonly boundary is shallow — nested object mutations silently write parent state, the most common invisible violation of encapsulation
  • 3. Filtered/derived arrays copy references, so mutating items received through them hits the shared source; emit identity or values instead
  • 4. The round trip — kebab-case props down, update:x events up, parent assigns — closes through reactivity with no manual synchronization anywhere
  • 5. Local prop mirrors drift; when a child must edit a normalized view, use a writable computed that emits in its setter

Interview Tips

  • • "Why can't a child modify its props?" — one writer per state: traceability, consistent derived readers, testable children, undo/batching in one place; then mention shallow readonly as the enforcement detail
  • • "Our app mutates nested props and it works — is that OK?" — no: shallow readonly means no warning and a hidden second writer; give the undo/two-list/shared-fixture failure stories and the emit refactor
  • • "Two components show different values from the same prop object — why?" — one of them mutates shared item references through a derived array; explain that filter copies references, not data
  • • "Walk me through a v-model-style round trip" — :prop down, @update:prop up, parent assigns, reactivity re-renders the child; note the kebab/camel transformation and that v-model is exactly this sugar

Cheat Sheet

One-Way Data Flow

The contract: props down (read-only) · events up · state changes only in the owner's code.

<!-- parent owns -->
<Stepper :current-quantity="q" :max="10" @update:quantity="q = $event" />
<!-- child -->
props = defineProps<{ currentQuantity: number; max: number }>()
emit('update:quantity', clamp(props.currentQuantity + d))

Enforcement:

Write Result
props.count++ dev warn Set operation ... target is readonly, dropped
props.todo.done = true silent — shallow readonly; mutates parent state directly
list.shift() on array prop blocked (new array binding), but element mutations pass through
child emits event → parent writes the contract

Why one writer: traceable mutations (devtools/undo record one stream) · derived readers stay consistent · children testable by asserting emitted events · reuse without hidden coupling.

Case bridge: kebab attributes (:current-quantity) ↔ camelCase props (currentQuantity) — compiler transformation; a mismatch sails into $attrs and the prop reads undefined.

Refactors: direct nested mutation → emit identity (toggle: [id]) · local mirror ref(props.x) (drifts) → writable computed that emits update:x in its setter.

Sugar: v-model:q ≡ :q + @update:q; the discipline underneath is not optional.

Continue Learning

Before You Start

Related Reading