- Sharing state and functions in template.
- Renderless/Headless components or components that allow overriding some of its content via slots.
Three ways to expose internal Vue components API
Weāve all been there, you got a component with an internal API (function or state) that you want to expose to the parent component, but with a lot of options to do so, which one is the best?
I recently answered a question on Twitter about this, and I thought it would be a good idea to write an article about it. Especially since we have more options in Vue 3 than we had in Vue 2 to tackle this problem.
Component API
First, letās define what a ācomponent internal APIā is. A component API usually consists of:
- State (props/data)
- Functions or methods
You are familiar with passing props to components to share state from parent to child, but what about the other way around? How can the child component pass back the state to the parent component?
You are also familiar with emitting events from child components to the parent, but what if the parent wants the child to execute a function? How can the parent component call a function on the child component?
So what we are discussing is the reverse of the āprops-down events-upā principle, we want somehow to pass the state upwards and sort of emit events downwards but not exactly like that.
Different nails, different hammers
There are a few ways we can expose an internal API to the parent component in Vue. Each excels in some cases and falls short in others.
This is just my opinion on the features/patterns Iām going to discuss, you might have a different opinion and thatās fine. Iām just sharing my experience with these patterns and when I think they are best used.
Slot props
This is the most popular one by far and has a lot of patterns associated with it. Whenever you find a library that advertises itself as āheadlessā or ārenderlessā itās probably using slot props. Here is an example of a headless <Countdown />
component.
vue<script setup>
import { ref, onMounted } from 'vue';
import { intervalToDuration } from 'date-fns';
const props = defineProps({
timestamp: {
type: Number,
required: true,
},
});
function getDuration() {
return intervalToDuration({
start: Date.now(),
end: props.timestamp,
});
}
const duration = ref(getDuration());
onMounted(() => {
setInterval(() => {
duration.value = getDuration();
}, 1000);
});
</script>
<template>
<slot v-bind="duration" />
</template>
This component exposes the duration
object to the parent component, which can then use it to render whatever it wants. Here is an example of how you would use it:
vue<script setup>
import Countdown from '@/components/Countdown.vue';
// week from now as an example
const endTime = Date.now() + 1000 * 60 * 60 * 24 * 7;
</script>
<template>
<div>
<h1>Huge sale ends in</h1>
<Countdown
v-slot="{ days, hours, minutes, seconds }"
:timestamp="endTime"
>
{{ days }}:{{ hours }}:{{ minutes }}:{{ seconds }}
</Countdown>
</div>
</template>
This is where slot props work best, the component has no idea how are you planning to present it. So the component just does the heavy lifting and provides you with a state or functions that you can use to render whatever you want. It doesnāt have to be ārenderlessā like the example, you can have a list component that lets you render each item however you want and it renders the rest of the component.
Provide/Inject shenanigans
This pattern became more relevant with the composition API and the typescript enhancements in Vue 3. If you are using plain JavaScript then I donāt recommend using this one at all because it is hard to reason about with.
So this pattern relies on the parent component providing a mutable object to the child component, and the child component injects it and mutates it (directly or indirectly) to share stuff back to the parent component. This example might be familiar if you know my work.
vue<script setup>
import { inject, reactive } from 'vue';
const props = defineProps({
name: String,
});
const form = inject('form');
const field = reactive({
value: '',
touched: false,
name: props.name,
});
form.register(field);
</script>
<template>
<input v-model="field.value" @blur="field.touched = true" />
</template>
And for this to make any sense, here is the parent component:
vue<script setup>
import { ref, provide, computed } from 'vue';
import FormField from './FormField.vue';
const props = defineProps({
name: String,
});
const fields = ref([]);
function register(state) {
fields.value.push(state);
}
const values = computed(() => {
return fields.value.reduce((acc, field) => {
acc[field.name] = field.value;
return acc;
}, {});
});
const touched = computed(() => {
return fields.value.some((field) => field.touched);
});
// Don't forget this!
provide('form', { register });
</script>
<template>
<div>
<FormField name="name" />
<FormField name="email" />
values: {{ values }} touched: {{ touched }}
</div>
</template>
Not the most ideal form component system but it shows cases where this pattern is ideal. This is how a lot of libraries implement hierarchy-sensitive components, you might have seen the following in the wild:
vue-html<CheckboxGroup>
<Checkbox />
<Checkbox />
<Checkbox />
</CheckboxGroup>
<SelectBox>
<SelectBoxItem />
<SelectBoxItem />
<SelectBoxItem />
</SelectBox>
So while it is ugly, it can be a very powerful pattern to use in your project. But I would only recommend it if you are using TypeScript and the composition API and you can justify the complexity it adds to your teammates. If you want to learn how to use typescript with provide/inject you can check this article where I covered some best practices for it.
I have no idea what to call this pattern but you can do anything with provide
and inject
so āshenanigansā seem to be most suitable here.
No point in trying to implement this using slot props because while possible (I wonāt ever show you how because it is very ugly and I donāt want to be responsible for that), letās just say it is not worth the effort.
Template Refs
First, letās recap what ātemplate refsā are. Whenever you want access to a DOM element or a Vue component instance in your script, you assign a ref
attribute to it. This populates the $refs
property if you are using the options API or the ref you created if you are using the composition API. Here is a quick example for both:
Options API:
vue<script>
export default {
mounted() {
this.$refs.input?.focus();
},
};
</script>
<template>
<input ref="input" />
</template>
Composition API:
vue<script>
import { ref } from 'vue';
const inputEl = ref();
onMounted(() => {
inputEl.value?.focus();
});
</script>
<template>
<input ref="inputEl" />
</template>
So the example component auto-focuses the input field whenever the component is mounted.
Given we have an InputText
component, we want to be able to do some stuff with it other than capturing user input. For example letās say you want to programmatically focus the input on demand, very much like how the native <input>
element works.
Here is a quick base component that we can use as a start:
vue<template>
<div>
<input ref="inputEl" v-model="value" />
</div>
</template>
<script>
import { ref } from 'vue';
const value = ref('');
// on mount, this will contain the value of the HTML element.
const inputEl = ref();
function focus() {
inputEl.value?.focus();
}
// Exposes the focus function to the parent component.
defineExpose({
focus,
});
</script>
Then in your parent component, you use it like this:
vue<script>
import { ref } from 'vue';
import InputText from '@/components/InputText.vue';
const inputRef = ref();
function focusThatInput() {
inputRef.value.focus();
}
</script>
<template>
<InputText ref="inputRef" />
<button @click="focusThatInput">Focus that input!</button>
</template>
This is a very cool functionality and it works similarly to native HTML elements and thatās when it works best. Whenever you have a component with DOM-like API, and especially functions.
To further drive this point home, consider using any of the previous patterns for this example, starting with slot props:
vue-html<InputText v-slot="{ focus }">
<!-- Wait a button inside the input? huh? -->
<button @click="focus">Focus that input!</button>
</InputText>
This is just confusing to any reader, also what if you want to call focus
in your script? There is no reasonable way to do that.
In larger templates, you will do some serious scoping gymnastics to get this to work.
It is a different story with the provide/inject
pattern, you only have to add a focus
function to the field
object.
jsimport { inject, ref, reactive } from 'vue';
const form = inject('form');
const input = ref();
function focus() {
input.value?.focus();
}
const field = reactive({
value: '',
touched: false,
name: props.name,
focus,
});
form.register(field);
I think that makes sense if you are building that sort of component system thatās meant to be used frequently also it scales well. But if it is a one-off situation, template refs are much more straightforward.
Conclusion
To recap, Iāve summarized when to use each pattern:
Scoped slots
When to use:
When NOT to use:
- Sharing state/functions in script, no good way to get the state across to the script without hacks.
- When the component scope becomes confusing, like a button inside an input component. The exposed props should be relevant to the component itself and what the slot is going to render.
Provide/Inject
When to use:
- You have some sort of a ācontrollerā component or a composable that needs awareness of specific child components and manages them under the hood.
- You have a component that needs to be aware of its siblings.
To sum it up, āhierarchal-awarenessā. vee-validate uses this pattern and so many other popular libraries in the Vue ecosystem.
When NOT to use:
- Not using TypeScript. Blindly injecting untyped stuff is a nightmare to maintain and explain.
- Setting up a provide/inject context just for a single component thatās only used once. Too much of an overkill. Will leave it to you to judge.
Template Refs
When to use:
- You have one-off functions you want to execute on a component.
- When you want to expose a DOM-like API to the parent component, like focusing an input, scrolling to an element, or proxying other DOM element functions.
Another example where I use this personally is a ScrollableContainer
component with custom scrollbars (because each OS has its ugly ones), so that component has a few interesting functions exposed like scrollToEnd
and scrollToTop
and isAtEnd
.
When NOT to use:
- When exposing state (hot take?).
- When you have a lot of components that you need to interact with. Having a lot of
ref
attributes could make a lot of noise in the composition API, if you are using the options API then it is fine.
I donāt like using this pattern with state because reactivity becomes a dodgy subject but works if you know what you are doing. However, the other patterns are much better at this and are easier.
If you are exposing state thatās meant to be used in the template then
use slot props, if you want to share state thatās meant to be used
in the script then use provide/inject
.
I hope you found this useful to pick out the best pattern fitting your needs. Remember that no one way is better than the others, look at what you are trying to do and pick the best tool possible for the job.