frontend / vue / 07_props_emits_vmodel.md

Props, Emits, and v-model

5 min read source

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 false to 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-modifiersnameModifiers.

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 warnprops.user.name = "x" modifies the parent’s reactive object. Vue can’t catch it without a readonly proxy. Treat all props as immutable.
  • Boolean prop with no value passes true<MyComponent disabled /> is disabled: true. Same as HTML.
  • Required prop missing generates a dev warning, not a runtime error.
  • v-model on <input type="checkbox"> with an array binds the checked values into the array — useful but easy to forget.
  • v-model.lazy on a custom component is not the same as on a native input — the modifier is exposed via modelModifiers and the component decides how to honor it.
  • Multiple roots + $attrs — without explicit v-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) and watch the prop to sync.”
  • v-model on a component is sugar over modelValue prop + update:modelValue emit. Vue 3’s multi-v-model replaces .sync and 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

Further reading