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:

  • Sharing state and functions in template.
  • Renderless/Headless components or components that allow overriding some of its content via slots.

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.

ホーム - Wiki
Copyright © 2011-2024 iteam. Current version is 2.139.0. UTC+08:00, 2024-12-28 23:16
浙ICP备14020137号-1 $お客様$