Props, Emits, and v-model
TL;DR
Vue’s parent-to-child contract is props (read-only in the child); child-to-parent is emits (typed events the child fires). v-model is sugar over props.modelValue + emit("update:modelValue", v) — Vue 3 supports multiple v-model bindings on a single component plus modifiers. Senior topics: typing props/emits with <script setup lang="ts">, why props are read-only, validation, and the multi-v-model pattern that replaces Vue 2’s .sync.
Interview Q&A
Q: How do you declare props in Composition API?
A: defineProps — a compiler macro in <script setup>:
<script setup lang="ts">
// Type-only declaration (preferred for TS projects)
const props = defineProps<{
title: string;
count?: number;
user: User;
}>();
// With defaults
withDefaults(defineProps<{ count?: number }>(), { count: 0 });
</script>
Runtime declaration (for validation / when not using TS):
<script setup>
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 },
callback: { type: Function as PropType<(x: number) => void> },
});
</script>
The type-only form is cleaner with TypeScript; the runtime form gives you validation warnings in dev.
Q: Why are props read-only?
A: Vue enforces one-way data flow: the parent owns the prop’s value; the child can read but not mutate.
<script setup>
const props = defineProps<{ count: number }>();
props.count = 5; // dev warning: "Set operation on key 'count' failed: target is readonly"
</script>
If you need to use a prop as initial value for local state, copy it:
const count = ref(props.count);
// optionally sync back when prop changes
watch(() => props.count, (v) => { count.value = v; });
This is the same pattern as React: don’t mutate props; lift state up or own a local copy.
Q: How do you declare emits?
A:
<script setup lang="ts">
// Type-only with payload tuples
const emit = defineEmits<{
submit: [value: string];
cancel: [];
change: [oldValue: number, newValue: number];
}>();
// Runtime — gives dev warnings on missing/typo'd events
const emit = defineEmits({
submit: (value: string) => typeof value === "string",
cancel: null,
});
emit("submit", "hello");
emit("change", 1, 2);
</script>
Declared emits give:
- TS autocomplete on the emit name.
- Dev warnings if the parent listens to an undeclared event (helps catch typos).
- Validation (runtime form) — return
falseto reject the payload.
Q: How does v-model work under the hood in Vue 3?
A: v-model="x" on a component is sugar for:
<MyInput :model-value="x" @update:model-value="x = $event" />
Inside the component:
<script setup>
defineProps<{ modelValue: string }>();
const emit = defineEmits<{ "update:modelValue": [value: string] }>();
</script>
<template>
<input :value="modelValue" @input="emit('update:modelValue', $event.target.value)" />
</template>
So a v-model component needs two contract pieces: a modelValue prop and an update:modelValue emit. That’s it.
(On native <input>/<select>/<textarea>, v-model does the right thing per element type — value/input, checked/change, etc. — that’s the framework’s compiler help.)
Q: Multiple v-model on one component.
A: Vue 3’s killer feature here — name the model:
<UserCard v-model:name="name" v-model:email="email" />
Inside:
<script setup>
defineProps<{ name: string; email: string }>();
const emit = defineEmits<{
"update:name": [v: string];
"update:email": [v: string];
}>();
</script>
This replaces Vue 2’s .sync modifier and is significantly cleaner. The unnamed v-model is just v-model:modelValue under the hood.
Q: v-model modifiers.
A: Built-ins: .lazy (sync on change instead of input), .number (cast to number), .trim (trim whitespace). Custom modifiers can be defined by reading from modelModifiers prop:
<MyInput v-model.capitalize="text" />
<script setup>
const props = defineProps<{ modelValue: string; modelModifiers?: { capitalize?: boolean } }>();
const emit = defineEmits<{ "update:modelValue": [v: string] }>();
function onInput(e: Event) {
let v = (e.target as HTMLInputElement).value;
if (props.modelModifiers?.capitalize) v = v.charAt(0).toUpperCase() + v.slice(1);
emit("update:modelValue", v);
}
</script>
Modifiers on named v-models use modelModifiersName, e.g. name-modifiers → nameModifiers.
Q: Validating props at runtime.
A: Using the runtime declaration form:
defineProps({
age: {
type: Number,
required: true,
validator: (v: number) => v >= 0 && v <= 130,
},
status: {
type: String,
validator: (v: string) => ["draft", "published", "archived"].includes(v),
},
});
Dev-only warnings when the parent passes an invalid value. Use this for components consumed by other teams — the warning is the contract.
Q: How do props/emits compare to React?
A:
| Vue | React | |
|---|---|---|
| Pass data down | props (read-only) | props (read-only by convention) |
| Pass behavior up | defineEmits + emit("name", payload) |
callback props (onSubmit={(v) => ...}) |
| Two-way binding | v-model (sugar over prop + emit) |
controlled component (value + onChange) |
| Multiple two-way | named v-model:foo |
multiple value/onChange pairs |
| Validation | runtime validator or TS types |
TS types (no runtime equivalent) |
Vue’s emit is essentially a typed callback prop with a different syntax — same idea.
Q: Fallthrough attributes.
A: Attributes passed to a component but not declared as props “fall through” to the root element by default. Useful for class, style, id, event listeners.
<!-- Parent -->
<MyButton class="primary" id="save" data-testid="save-btn" onClick={...} />
<!-- MyButton: only declares text prop -->
<script setup>
defineProps<{ text: string }>();
</script>
<template>
<button>{{ text }}</button> <!-- class, id, data-testid all land here -->
</template>
Multi-root components require you to bind $attrs explicitly to the desired root:
<template>
<span>label</span>
<input v-bind="$attrs" />
</template>
Disable fallthrough with defineOptions({ inheritAttrs: false }).
Gotchas / edge cases
- Mutating an object prop’s contents doesn’t warn —
props.user.name = "x"modifies the parent’s reactive object. Vue can’t catch it without areadonlyproxy. Treat all props as immutable. - Boolean prop with no value passes
true—<MyComponent disabled />isdisabled: true. Same as HTML. - Required prop missing generates a dev warning, not a runtime error.
v-modelon<input type="checkbox">with an array binds the checked values into the array — useful but easy to forget.v-model.lazyon a custom component is not the same as on a native input — the modifier is exposed viamodelModifiersand the component decides how to honor it.- Multiple roots +
$attrs— without explicitv-bind="$attrs"on a chosen root, attrs go nowhere; this is a quiet bug.
What a senior is expected to say
- “Props are read-only by contract; mutating an object prop’s properties works but breaks one-way data flow — treat them as immutable. Local state should
ref(props.x)andwatchthe prop to sync.” - “
v-modelon a component is sugar overmodelValueprop +update:modelValueemit. Vue 3’s multi-v-modelreplaces.syncand is cleaner.” - “Type-only
defineProps<...>()/defineEmits<...>()in<script setup lang='ts'>for TS projects; runtime declarations when I want validators or non-TS validation.” - “Fallthrough attrs land on the root element by default; multi-root components must
v-bind=\"$attrs\"explicitly.” - “Emits are typed callbacks. Declaring them gives autocomplete in templates and warns the parent on typos.”
Cross-references
- Composition API +
<script setup>: 05_composition_vs_options.md - Slots (the other composition primitive): 08_slots_and_scoped_slots.md
Further reading
- Vue docs — Props: https://vuejs.org/guide/components/props.html
- Vue docs — Events: https://vuejs.org/guide/components/events.html
- Vue docs —
v-model: https://vuejs.org/guide/components/v-model.html