Avoid Mutating a Prop Directly: Vue One-Way Data Flow
Props flow down only — mutating them warns and gets overwritten.
20+ years shipping production backend systems. Drawn from code that ran under real load.
- ✓Vue component basics: props down, events up
- ✓v-model fundamentals on native inputs
- ✓How JavaScript object references differ from primitives
- Props are read-only: child mutations warn in dev and get overwritten on the next parent render
- For editable values, emit update:modelValue (v-model) and let the parent own the change
- For local editing, copy the prop into a ref on setup and emit the result on save
- Never mutate prop objects or arrays in place — parents share the reference and corrupt silently
- Trace the warning to the exact line: search the child for assignments to prop names
Imagine a library book: you can read it at home, but writing in the margins ruins it for the next borrower — and the librarian replaces your copy anyway. Vue props are library books lent by the parent. A child that scribbles on them gets a warning, and the next parent render overwrites the scribbles. Request a new edition (emit) or photocopy the pages (local copy) instead.
You build a form component, wire an input to a prop, type a character — and Vue logs Avoid mutating a prop directly. It might even seem to work at first, until the parent re-renders and your edits vanish, or a sibling component shows values you never typed. Beginners hit this in week one; veterans hit it whenever shared objects sneak through props.
The rule is one-way data flow: props travel parent-to-child, and only the owner (the parent) may change them. A child that assigns this.title or pushes into a prop array mutates state it doesn't own. Vue warns because the next parent render will overwrite the child's change, producing edits that randomly disappear — the most confusing symptom in component development.
This article makes the rule instinctive: why one-way flow exists, the v-model emit pattern for values the parent should own, the local-copy pattern for draft-then-save editing, and the shared-reference trap that makes object props dangerous. You'll stop fighting the warning and start designing component interfaces that never trigger it.
One-Way Data Flow: Why Props Are Read-Only
Props flow down from parent to child, and ownership stays with the parent. The parent's render recreates prop values on every update, so anything the child wrote directly gets overwritten — edits that vanish seemingly at random. Vue's dev warning exists to catch this ownership violation at authoring time: the child is changing state it doesn't own, and the framework knows the change can't survive.
One-way flow is what makes component behavior predictable. Data moves in a single direction — parent state down through props, child intentions up through events — so any value on screen traces back to exactly one owner. Two-way mutation would let any descendant rewrite any ancestor's state invisibly, turning debugging into archaeology. The restriction feels limiting for about a day; then it becomes the reason large Vue apps stay comprehensible.
The practical consequence: treat props as frozen the moment they arrive. Read them freely in templates, computeds, and render logic. The instant you need a different value — an edited draft, a toggled flag, a sorted copy — you need one of the patterns below, not an assignment. Internalize props-are-read-only and the warning becomes a rare visitor instead of a daily annoyance.
The v-model Contract: Emitting Updates the Parent Owns
When the parent should own the live value — search boxes, toggles, form fields — use the v-model contract. The child declares a modelValue prop and emits update:modelValue with each new value; the parent's v-model binding applies it. The child never assigns the prop; it requests the change and the parent performs it. Ownership stays in one place while editing feels instant.
In script setup the pattern is three lines: defineProps with modelValue, defineEmits with update:modelValue, and an emit call in the input handler passing $event.target.value (or the parsed value). For checkboxes and custom controls, emit the semantic value — checked boolean, selected id — not the raw DOM event. Multiple v-models (v-model:title plus v-model:content) scale the same contract per prop with update:title and update:content events.
Debug v-model failures at the contract points: is modelValue declared, is the exact update:modelValue event emitted (casing matters), and does the parent use v-model rather than one-way :modelValue? A parent that passes :modelValue without listening renders the initial value and ignores every keystroke — the classic half-wired symptom. Log the emit payload to separate child silence from parent deafness.
Local Copies for Draft-Then-Save Editing
Sometimes the child should own an editing draft — dialogs, settings forms, wizards where Cancel must discard changes. Copy the prop into local reactive state at setup (ref(props.value) for primitives, structuredClone or spread for objects), bind inputs to the copy, and emit the finished result on save. The parent's data stays pristine until the child explicitly commits.
Sync policy is the design decision: when the parent sends a new prop mid-edit, does the draft reset or preserve in-flight typing? Watch the prop and choose deliberately — reset for dialogs reopened on fresh records, preserve-and-notify for live-collaboration surfaces. Document the choice in a comment; the next author will otherwise guess wrong. Always re-clone (never assign the prop object itself) so the draft stays an independent copy.
Validate before emitting: the save handler checks the draft, emits a single apply event with the clean payload, and lets the parent merge it into owned state. Cancel simply closes, discarding the copy. This pattern eliminates an entire class of half-edited-state bugs because uncommitted changes physically cannot leak into parent state — they live in a variable the parent never sees.
The Shared-Reference Trap With Object and Array Props
Primitives copy on assignment, but objects and arrays pass by reference — the child's prop points at the parent's actual object. Mutating props.filters.push(...) or props.user.name = x rewrites parent state directly, bypassing events entirely. In dev this warns (for direct prop writes); in production builds the warning compiles away and corruption runs silently. Siblings sharing the reference all observe the damage, which is how one tab's edits rewrote every other tab.
The defense has three layers. First, clone on receipt: treat every object prop as frozen and spread or structuredClone it before use. Second, freeze shared defaults with Object.freeze so accidental writes throw instead of corrupting — a loud failure beats a silent one. Third, design parents to hold per-consumer state (per-tab filters) rather than passing one instance to many children.
Sorting and filtering deserve special care: Array.prototype.sort and reverse mutate in place, so sorting a prop array for display corrupts the parent. Always .slice() before sorting, or derive with a computed that copies first. Review every .push, .splice, .sort, and key assignment touching a prop — each is a potential cross-component corruption vector hiding behind familiar syntax.
Computed Setters: the Elegant Bridge Between Prop and Emit
For simple two-way-looking bindings, a computed with a getter and setter is the cleanest pattern. The getter returns the prop; the setter emits the update event. Templates bind v-model to the computed and read naturally, while every write routes through the emit — ownership preserved, ergonomics perfect. It's the v-model contract dressed in computed clothing.
This shines for transformed values: a prop storing cents with an input editing dollars, where the setter parses and emits the canonical form. Validation fits too — the setter can reject or clamp before emitting, keeping the parent's state always valid without the child owning anything. Each computed bridges exactly one prop to exactly one event, keeping the mapping auditable.
Don't overuse it. For multi-field drafts, the local-copy pattern is clearer than a forest of bridging computeds. And never let a computed setter mutate the prop as a shortcut — a setter that assigns props.x instead of emitting recreates the original bug behind prettier syntax. The setter's only legitimate write target is the emit call (plus local UI state like touched flags). Review setters with one test: does every write path end in emit? If not, rewrite it.
Designing Component Interfaces That Can't Be Misused
The best fix is an interface that makes mutation unnatural. Name props as nouns (value, filters, user) and events as verbs (update, apply, save) so the direction is grammatically obvious. Prefer v-model for live-owned values and explicit apply/save events for drafts — consumers shouldn't guess which pattern a component expects. Document the contract in prop comments: read-only, cloned internally, emitted on change.
Type your boundaries. With TypeScript, readonly modifiers on prop interfaces turn assignments into compile errors instead of runtime warnings. runtime validators (required, type checks, custom validators) catch missing or misshapen props at the door. The stricter the interface, the fewer ways consumers can misuse it — and misuse is what this warning reports.
Test the contract from both sides. Mount the child, simulate edits, and assert the prop object is deep-unchanged while the emitted payload is correct. Mount two siblings on one shared object, edit one, and assert isolation. These tests encode one-way flow as executable specification: any future shortcut that mutates a prop fails CI instead of corrupting a dashboard for two weeks. Contracts untested are contracts unsigned.
The Shared Filters Object That Corrupted Every Dashboard Tab
- Object and array props are shared references — mutating them corrupts every consumer silently. Clone on receipt, emit on change, every time.
- Dev-only warnings don't protect production. Freeze shared defaults and test multi-consumer isolation so the bug fails loudly before users find it.
- Saved-view and tab state must be per-instance. A single shared defaults object passed to siblings is cross-contamination by construction.
| File | Command / Code | Purpose |
|---|---|---|
| TitleDisplay.vue | <script setup> | One-Way Data Flow |
| SearchBox.vue | <script setup> | The v-model Contract |
| UserDialog.vue | <script setup> | Local Copies for Draft-Then-Save Editing |
| FilterPanel.vue | <script setup> | The Shared-Reference Trap With Object and Array Props |
| ValueBridge.vue | <script setup> | Designing Component Interfaces That Can't Be Misused |
Key takeaways
Common mistakes to avoid
5 patternsBinding inputs directly to a prop with v-model="propName"
Pushing into or sorting a prop array in place
Skipping the re-clone watch on local draft copies
Passing one defaults object to many sibling components
Writing computed setters that assign the prop instead of emitting
Interview Questions on This Topic
Why does Vue warn when a child mutates a prop?
Frequently Asked Questions
20+ years shipping production backend systems. Drawn from code that ran under real load.
That's Vue. Mark it forged?
5 min read · try the examples if you haven't