Mo' models mo' problems

2018-08-14 #vue #javascript

One of the best things about Vue templates is the special v-model prop. v-model allows you two quickly map prop getters and setters without breaking unidirectional data flow. Props down, events up.

<!-- People.vue -->
<template>
<filter v-model="filter"></filter>
<ul>
<li v-for="person in filteredPeople" :key="person.name">
{{ person.name }}
</li>
</ul>
</template>
 
<script>
import Filter from "./Filter";
 
export default {
data() {
return {
filter: "",
people: [
/* ... */
]
};
},
 
components: { Filter },
 
computed: {
filteredPeople() {
// Return filtered `people` array result
}
}
};
</script>

For now, we assume the filter prop is a plain string. The v-model property expands to a :value prop and an @input event listener.

<filter v-model="filter"></filter>
 
<!-- Expands to... -->
 
<filter :value="filter" @input="filter = $event"></filter>

Inside the Filter component, we registered an @input listener to an input element, which emits the value to the parent component.

<!-- Filter.vue -->
<template>
<input
type="search"
:value="value"
@input="$emit('input', $event.target.value)"
/>
</template>
 
<script>
export default {
props: {
value: { required: true, type: String }
}
};
</script>

This works great for simple inputs, but what if we want to expand our filter to an object?

<!-- People.vue -->
<template>
<filter v-model="filter"></filter>
<!-- ... -->
</template>
 
<script>
export default {
data() {
return {
filter: {
query: "",
job: ""
}
};
}
 
// ...
};
</script>

We're not allowed to modify props in Vue, so we need to emit a modified copy of the filter prop from our Filter component.

<!-- Filter.vue -->
<template>
<div>
<input
type="search"
:value="value.query"
@input="$emit('input', {
...value,
query: $event.target.value
})"
/>
<select
:value="value.job"
@input="$emit('input', {
...value,
job: $event.target.value
})"
>
<option value="">All</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Account manager</option>
</select>
</div>
</template>
 
<script>
export default {
props: {
value: { required: true, type: Object }
}
};
</script>

This works, but Filter became a black box: it's not immediately clear what value contains. We need to dive into the components implementation details to discover it expects query and job keys.

We can solve this by down passing each filter key individually, and emitting multiple, key-specific events.

<!-- People.vue -->
<template>
<filter
:query="filter.query"
:job="filter.job"
@queryinput="filter.query = $event"
@jobinput="filter.job = $event"
></filter>
<!-- ... -->
</template>
<!-- Filter.vue -->
<template>
<div>
<input
type="search"
:value="query"
@input="$emit('queryinput', $event.target.value)"
/>
<select :value="job" @input="$emit('jobinput', $event.target.value)">
<option value="">All</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Account manager</option>
</select>
</div>
</template>
 
<script>
export default {
props: {
value: { required: true, type: String },
job: { required: true, type: String }
}
};
</script>

We've greatly improved Filter's public API. Someone using the component now knows it expects two distinct props: query and job.

Unfortunately, by making Filter more explicit, we've punished the consumer. The parent now needs to pass a prop and register an event listener for each filter key.

Compared to how concise v-model was, this feels like a regression. We had to invent our own API.

Vue provides a more generic alternative to v-model: the .sync modifier.

<!-- People.vue -->
<template>
<filter :query.sync="filter.query" :job.sync="filter.job"></filter>
<!-- ... -->
</template>

sync works just like v-model, except it listens for an update:[key] event. The above expands to our two props, and two listeners.

<!-- People.vue -->
<template>
<filter
:query="filter.query"
:job="filter.job"
@update:query="filter.query = $event"
@update:job="filter.job = $event"
></filter>
</template>

If we rename the emitted events in Filter, we get the best of both words: seemingly two way data binding while maintaining an explicit component API.

<!-- Filter.vue -->
<template>
<div>
<input
type="search"
:value="query"
@input="$emit('update:query', $event.target.value)"
/>
<select :value="job" @input="$emit('update:job', $event.target.value)">
<option value="">All</option>
<option value="developer">Developer</option>
<option value="designer">Designer</option>
<option value="manager">Account manager</option>
</select>
</div>
</template>
 
<script>
export default {
props: {
value: { required: true, type: String },
job: { required: true, type: String }
}
};
</script>

When refactoring your UI to components, always keep in mind that props and events are your components public API.

Just like when you're modelling your application's domain, try to keep things explicit. Props and events should be enough to tell the outside world everything it needs to know about a component's behavior.