Chapter 13

v-model, v-for and Custom Directives: Compilation Output and Runtime Handling

Chapter 13: v-model, v-for, Custom Directives โ€” Compiler Expansion and Runtime Handling

v-model is not a directive โ€” it's syntactic sugar. During compilation it's expanded into :modelValue and @update:modelValue. Understanding this expansion is the key to truly mastering two-way binding.

Core Question: How does v-model work on native elements vs custom components? How do custom directive lifecycle hooks correspond to component hooks? Why doesn't v-show unmount the DOM?

After Reading This Chapter, You Will Understand:


Level 1 ยท What You Need to Know (1โ€“3 Years Experience)

v-model's Two Expansion Forms

On native HTML elements:

<!-- Template syntax -->
<input v-model="message" />

<!-- Compilation expansion equivalent -->
<input 
  :value="message" 
  @input="message = $event.target.value" 
/>

On Vue components:

<!-- Template syntax -->
<MyInput v-model="message" />

<!-- Compilation expansion equivalent -->
<MyInput 
  :modelValue="message" 
  @update:modelValue="message = $event" 
/>

For a component to support v-model:

  1. Accept a modelValue prop
  2. emit('update:modelValue', newValue) at the appropriate time
<!-- MyInput component implementation -->
<script setup>
const props = defineProps({ modelValue: String })
const emit = defineEmits(['update:modelValue'])

function handleInput(e) {
  emit('update:modelValue', e.target.value)
}
</script>

<template>
  <input :value="props.modelValue" @input="handleInput" />
</template>

Multiple v-model and Named Arguments

Vue 3 supports multiple v-model on the same component, distinguished by argument names:

<!-- Multiple v-model -->
<MyForm v-model:title="formTitle" v-model:content="formContent" />

<!-- Expansion equivalent -->
<MyForm 
  :title="formTitle"   @update:title="formTitle = $event"
  :content="formContent" @update:content="formContent = $event"
/>

Component implementation:

<script setup>
const props = defineProps({ title: String, content: String })
const emit = defineEmits(['update:title', 'update:content'])
</script>

Built-in v-model Modifiers

Vue 3 has three built-in modifiers:

<!-- .lazy: changes input event to change event (fires on blur) -->
<input v-model.lazy="msg" />

<!-- .number: converts input value to number (parseFloat) -->
<input v-model.number="age" />

<!-- .trim: auto-trims leading/trailing whitespace -->
<input v-model.trim="name" />

v-for Expansion Form

<li v-for="(item, index) in list" :key="item.id">
  {{ item.name }}
</li>

Compiled expansion:

renderList(list, (item, index) => {
  return withKey(
    createVNode("li", null, item.name, 1 /* TEXT */),
    item.id
  )
})

renderList is Vue 3's internal function that handles different source types (array, object, number, string, iterables).

Custom Directives

// Define a directive
const vFocus = {
  mounted(el) {
    el.focus()
  }
}

// In <script setup>, variables starting with 'v' auto-become directives
<template>
  <input v-focus />
</template>

Global registration:

app.directive('focus', {
  mounted(el) { el.focus() }
})

Level 2 ยท How It Actually Works (3โ€“5 Years Experience)

v-model Differences Across Native Element Types

Different input types expand differently:

<!-- text / textarea / email / password etc. -->
<input type="text" v-model="text" />
<!-- โ†’ :value + @input="text = $event.target.value" -->

<!-- checkbox (single) -->
<input type="checkbox" v-model="checked" />
<!-- โ†’ :checked + @change="checked = $event.target.checked" -->

<!-- checkbox (multiple, bound to array) -->
<input type="checkbox" value="option1" v-model="checkedValues" />
<!-- โ†’ :checked (checks if checkedValues includes "option1")
    @change (adds/removes "option1" from checkedValues based on checked state) -->

<!-- radio -->
<input type="radio" value="A" v-model="picked" />
<!-- โ†’ :checked="picked === 'A'" + @change="picked = 'A'" -->

<!-- select (single) -->
<select v-model="selected">...</select>
<!-- โ†’ :value + @change -->

<!-- select (multiple) -->
<select multiple v-model="selectedItems">...</select>
<!-- โ†’ selectedItems = array of selected option values -->

Special checkbox extension:

<input type="checkbox" v-model="val" true-value="yes" false-value="no" />

Vue extends checkbox to support custom values instead of true/false.

v-model Modifier Compilation Details

.lazy modifier: Changes event from input to change .number modifier: parseFloat(value) or original string if NaN .trim modifier: value.trim()

Passing modifiers to component v-model:

<MyInput v-model.trim.number="value" />

<!-- Expands to -->
<MyInput 
  :modelValue="value"
  :modelModifiers="{ trim: true, number: true }"
  @update:modelValue="value = $event" 
/>

Component handles modifiers:

<script setup>
const props = defineProps({
  modelValue: [String, Number],
  modelModifiers: { default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])

function handleInput(e) {
  let value = e.target.value
  if (props.modelModifiers.trim) value = value.trim()
  if (props.modelModifiers.number) {
    const num = parseFloat(value)
    value = isNaN(num) ? value : num
  }
  emit('update:modelValue', value)
}
</script>

v-for's renderList Internal Implementation

// packages/runtime-core/src/helpers/renderList.ts

export function renderList(
  source: any,
  renderItem: (...args: any[]) => VNodeChild,
): VNodeChild[] {
  let ret: VNodeChild[]

  if (isArray(source) || isString(source)) {
    // Array or string: direct iteration
    ret = new Array(source.length)
    for (let i = 0, l = source.length; i < l; i++) {
      ret[i] = renderItem(source[i], i)
    }
  } else if (typeof source === 'number') {
    // Number n: generates sequence 1 to n
    ret = new Array(source)
    for (let i = 0; i < source; i++) {
      ret[i] = renderItem(i + 1, i)
    }
  } else if (isObject(source)) {
    if (source[Symbol.iterator as any]) {
      // Iterable (Set, Map, etc.)
      ret = Array.from(source as Iterable<any>, (item, i) => renderItem(item, i))
    } else {
      // Plain object: iterate key-value pairs
      const keys = Object.keys(source)
      ret = new Array(keys.length)
      for (let i = 0, l = keys.length; i < l; i++) {
        const key = keys[i]
        ret[i] = renderItem(source[key], key, i)
      }
    }
  } else {
    ret = []
  }
  
  return ret
}

renderList source type support:

Source Type Template Example Render Params
Array v-for="(item, index) in arr" (item, index)
String v-for="char in str" (char, index)
Number n v-for="n in 5" (n, index), n from 1 to 5
Plain object v-for="(val, key, index) in obj" (value, key, index)
Iterable v-for="item in set" (item, index)

v-if and v-for Priority Conflict

Do not use v-if and v-for on the same element in Vue 3 (v-if has higher priority in Vue 3, meaning the v-if condition cannot access v-for's iteration variable):

<!-- โŒ Not recommended: v-if can't access 'item' -->
<li v-for="item in list" v-if="item.active">{{ item.name }}</li>

<!-- โœ… Recommended: wrap with template v-for -->
<template v-for="item in list" :key="item.id">
  <li v-if="item.active">{{ item.name }}</li>
</template>

<!-- โœ… Or filter in computed -->
<li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>

Custom Directive Lifecycle Hooks

Custom directives have 7 lifecycle hooks corresponding to component lifecycle:

const myDirective = {
  created(el, binding, vnode, prevVNode) {},
  beforeMount(el, binding, vnode, prevVNode) {},
  mounted(el, binding, vnode, prevVNode) {},
  beforeUpdate(el, binding, vnode, prevVNode) {},
  updated(el, binding, vnode, prevVNode) {},
  beforeUnmount(el, binding, vnode, prevVNode) {},
  unmounted(el, binding, vnode, prevVNode) {},
}

The binding object structure:

interface DirectiveBinding<V> {
  instance: ComponentPublicInstance | null  // Component instance
  value: V                   // Bound value: the "value" in v-my-dir="value"
  oldValue: V | null        // Previous value (available in updated/beforeUpdate)
  arg: string | undefined   // Argument: the "arg" in v-my-dir:arg="value"
  modifiers: DirectiveModifiers  // {trim: true, number: true} from v-my-dir.trim.number
  dir: ObjectDirective<any, V>  // The directive object itself
}

Practical example: click-outside directive:

const vClickOutside = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const handler = (event: MouseEvent) => {
      if (!el.contains(event.target as Node)) {
        binding.value(event)   // Call the bound function
      }
    }
    el._clickOutsideHandler = handler
    document.addEventListener('click', handler)
  },
  unmounted(el: HTMLElement) {
    document.removeEventListener('click', el._clickOutsideHandler)
    delete el._clickOutsideHandler
  }
}

// Usage: <div v-click-outside="handleClose">...</div>

Custom Directive Compiled Output

<div v-my-dir:arg.mod1.mod2="value">Content</div>

Compiled output:

import { withDirectives, resolveDirective, createElementVNode } from "vue"

const vMyDir = resolveDirective("my-dir")

withDirectives(
  createElementVNode("div", null, "Content"),
  [
    [
      vMyDir,                          // Directive object
      value,                           // Bound value
      "arg",                           // Argument
      { mod1: true, mod2: true }       // Modifiers
    ]
  ]
)

v-show implementation โ€” display toggle:

// packages/runtime-dom/src/directives/vShow.ts

export const vShow: ObjectDirective<VShowElement> = {
  beforeMount(el, { value }, { transition }) {
    el._vod = el.style.display === 'none' ? '' : el.style.display
    if (transition && value) {
      transition.beforeEnter(el)
    } else {
      setDisplay(el, value)
    }
  },
  updated(el, { value, oldValue }, { transition }) {
    if (!value === !oldValue) return   // No change
    if (transition) {
      if (value) {
        transition.beforeEnter(el)
        setDisplay(el, true)
        transition.enter(el)
      } else {
        transition.leave(el, () => setDisplay(el, false))
      }
    } else {
      setDisplay(el, value)
    }
  },
  beforeUnmount(el, { value }) {
    setDisplay(el, value)
  },
}

function setDisplay(el: VShowElement, value: unknown): void {
  el.style.display = value ? el._vod : 'none'
}

v-show vs v-if comparison:

Dimension v-if v-show
DOM operation Create / destroy DOM Toggle display
Child component state Destroyed (lifecycle resets) Preserved
Performance Fast first render, slow toggle Slow first render, fast toggle
Best for Rarely-toggled conditions Frequently-toggled conditions
With keep-alive Needed Not needed (state preserved naturally)

Level 3 ยท Design Docs and Source Code (Senior Developers)

transformModel: v-model Compilation Transform

File: packages/compiler-dom/src/transforms/transformModel.ts

For component v-model, the transform generates:

For native element v-model, it dispatches to the correct runtime directive based on element type and input type:

withDirectives Runtime Implementation

File: packages/runtime-core/src/directives.ts

export function withDirectives<T extends VNode>(
  vnode: T,
  directives: DirectiveArguments,
): T {
  const instance = getExposeProxy(currentRenderingInstance) || currentRenderingInstance.proxy
  const bindings: DirectiveBinding[] = vnode.dirs || (vnode.dirs = [])
  
  for (let i = 0; i < directives.length; i++) {
    let [dir, value, arg, modifiers = EMPTY_OBJ] = directives[i]
    
    if (isFunction(dir)) {
      // Function form: equivalent to { mounted: dir, updated: dir }
      dir = { mounted: dir, updated: dir } as ObjectDirective
    }
    
    bindings.push({ dir, instance, value, oldValue: void 0, arg, modifiers })
  }
  
  return vnode
}

Directive hook invocation timing in the renderer:

mount phase:
  mountElement
    โ†’ invokeDirectiveHook(..., 'created')       [before props processing]
    โ†’ invokeDirectiveHook(..., 'beforeMount')   [before DOM insertion]
    โ†’ DOM insertion
    โ†’ queuePostFlushCb: invokeDirectiveHook(..., 'mounted')

update phase:
  patchElement
    โ†’ invokeDirectiveHook(..., 'beforeUpdate')  [before DOM update]
    โ†’ update DOM (props, children)
    โ†’ queuePostFlushCb: invokeDirectiveHook(..., 'updated')

unmount phase:
  unmountElement
    โ†’ invokeDirectiveHook(..., 'beforeUnmount') [before DOM removal]
    โ†’ remove from DOM
    โ†’ invokeDirectiveHook(..., 'unmounted')

invokeDirectiveHook Source

export function invokeDirectiveHook(
  vnode: VNode,
  prevVNode: VNode | null,
  instance: ComponentInternalInstance | null,
  name: keyof ObjectDirective,
) {
  const bindings = vnode.dirs!
  const oldBindings = prevVNode && prevVNode.dirs!
  
  for (let i = 0; i < bindings.length; i++) {
    const binding = bindings[i]
    if (oldBindings) {
      binding.oldValue = oldBindings[i].value
    }
    let hook = binding.dir[name] as DirectiveHook | undefined
    if (hook) {
      // Pause tracking: prevent reactive reads in directive hooks
      // from interfering with the render effect
      pauseTracking()
      callWithAsyncErrorHandling(hook, instance, ErrorCodes.DIRECTIVE_HOOK, [
        vnode.el,    // el: the DOM element
        binding,     // binding: value, arg, modifiers, etc.
        vnode,       // vnode: current VNode
        prevVNode,   // prevVNode: previous VNode
      ])
      resetTracking()
    }
  }
}

Level 4 ยท Edge Cases and Traps (All Experience Levels)

Trap 1: Destructuring reactive() Breaks v-model Reactivity

<script setup>
const form = reactive({ name: '', email: '' })
</script>

<template>
  <!-- โœ… Works: reactive object properties are tracked -->
  <input v-model="form.name" />
</template>

But:

<script setup>
// โŒ Destructuring breaks reactivity โ€” name and email are plain strings
const { name, email } = reactive({ name: '', email: '' })
</script>

<template>
  <input v-model="name" />  <!-- Will NOT update reactively -->
</template>

Solution: Use toRefs():

<script setup>
const state = reactive({ name: '', email: '' })
const { name, email } = toRefs(state)  // โœ… Ref objects, v-model works
</script>

Trap 2: Custom Directive Mutating Vue-Managed DOM Attributes Can Conflict

// โŒ Risky: modifying class may be overwritten by Vue's class binding
const vDanger = {
  mounted(el) {
    el.className = 'red'   // Vue's :class binding may overwrite this
  },
}

Recommended pattern: Direct DOM attributes that Vue doesn't manage:

Use the updated hook (fires after Vue finishes DOM updates) when you need to react to changes.

Trap 3: v-show Conflicts with CSS display !important

.my-component {
  display: flex !important;
}
<!-- v-show uses inline style (lower specificity than !important) -->
<div class="my-component" v-show="isVisible">
  <!-- !important prevents v-show from hiding this element -->
</div>

Root cause: v-show sets el.style.display (inline style), but !important in CSS overrides inline styles.

Solutions:

  1. Don't use !important display on elements that use v-show
  2. Use v-if instead
  3. Wrap with an outer element: outer has v-show, inner has the flex class

Trap 4: Custom v-model Modifiers Not Received by Component

<!-- Parent -->
<MyInput v-model.trim.number="value" />
<!-- Child: common mistake -->
<script setup>
const props = defineProps({
  modelValue: String
  // โŒ Missing modelModifiers declaration!
})
</script>

Without declaring modelModifiers, the modifier information is lost.

Correct implementation:

<script setup>
const props = defineProps({
  modelValue: [String, Number],
  modelModifiers: {
    default: () => ({})   // โœ… Always declare with default empty object
  }
})
</script>

For named v-model (v-model:title):

<script setup>
const props = defineProps({
  title: String,
  titleModifiers: {   // โ† Modifier prop name = "{argName}Modifiers"
    default: () => ({})
  }
})
</script>

Trap 5: Directive Limitations in SSR

// โŒ DOM-only operations fail in SSR
const vFocus = {
  mounted(el) {
    el.focus()   // โŒ No DOM in SSR โ€” el.focus doesn't exist
  }
}

Solution: Provide getSSRProps for SSR-compatible directives:

const vFocus = {
  mounted(el) {
    el.focus()
  },
  getSSRProps(binding) {
    // Return props to add to element during SSR rendering
    // focus has no SSR meaning, return empty
    return {}
  }
}

Or use conditional check:

const vFocus = {
  mounted(el) {
    if (typeof el?.focus === 'function') {
      el.focus()
    }
  }
}

Chapter Summary

  1. v-model is compile-time syntactic sugar: On native elements it expands to :value + @input (or element-appropriate equivalents); on components it expands to :modelValue + @update:modelValue. Multiple v-model instances use argument names to distinguish; modifiers are passed to components via modelModifiers props.

  2. v-for's renderList handles multiple source types: Arrays, strings, numbers (1-to-n sequences), plain objects (key-value pairs), and iterables (Set, Map). v-for compiles to renderList(source, renderFn); keys are used as VNode key attributes at compile time, enabling keyed diff at runtime.

  3. Custom directives have 7 hooks corresponding to the component lifecycle: created/beforeMount/mounted/beforeUpdate/updated/beforeUnmount/unmounted, precisely invoked during the renderer's patch process. Directives can be objects (multiple hooks) or functions (shorthand for mounted + updated).

  4. withDirectives is the bridge between directives and VNodes: It attaches directive information (bindings) to the VNode's dirs array. The runtime calls the appropriate hooks via invokeDirectiveHook at each patch stage, with tracking paused to prevent interference with render effects.

  5. v-show is a built-in display-toggle directive; v-if is Block replacement: v-show preserves DOM and component state (great for frequent toggling); v-if completely unmounts/mounts (right for rarely-toggled conditions or when true destruction is needed). They represent two different "visibility management" semantics, not simply a performance trade-off.

Rate this chapter
4.7  / 5  (24 ratings)

๐Ÿ’ฌ Comments