Recipes to Wrap, Extend or Proxy a Vue Component

Michael Gallagher
Vue.js Developers
Published in
6 min readJun 15, 2021

With an exciting new third-party component to introduce to your project, you think, hmm, what about DRY? I’ll wrap it!

This article details some ways to create a component that extends another. We’ll look at attributes, props, listeners, and slots, along with functional components and render functions. The syntax will generally be Vue v2.6+ with additional notes for Vue 3.

For the purposes of illustration, the examples use v-popover from the v-tooltip library as the inner component, and will create a HelloPopover outer component which is used inside App.

Basic Example

So let’s get the simplest one out of the way.

Nothing from the inner component’s interface is exposed on the outer component. Or almost nothing.

To make this a little more interesting, let’s see what happens when we set some HTML attributes, at both levels.

HTML Render of HelloPopover

Out-of-the-box the HTML attributes are proxied to the inner component, and both Class and Style attributes have their values merged. If something different is required, take a look at inheritAttrs.

Straight Through

After completely specifying all the behaviour in the outer component, let’s look at the exact opposite, complete pass-through.

Note for Vue 3: v-on="$listeners" can be removed, and $scopedSlots will just be $slots (details).

So this version will proxy all the attributes, props, listeners, and slots straight through to the inner component. As the outer component doesn’t declare any props, anything provided to it will be under $attrs.

As for events and listeners, it might have seemed more logical to proxy the events themselves rather than proxying listeners. But this way is much cleaner and simpler.

Normally slots are accessed using <slot/> but a component also provides programmatic access via $scopedSlots.

Still, this example is a little boring, we’re only sending in slots. How about…

Now the App component has 2 local data properties, open and highlight. Open is bound to HelloPopover with async modifier, and highlight if true will apply a background to the popover root element.

A toggle button will flip the value of open, and some native events for mouseover and mouseout on the popover root element will control the value of highlight.

Note for Vue 3: v-model:open="open" will replace the bind and sync, and the native modifiers can be removed.

What’s proven here is that we can use the same interface on HelloPopover as we would on v-popover and even get through to native events on the popover root element if needed.

Tapped Props

Still, the outer component isn’t adding any value. What if we want to “tap” the value of open which can be changed from the App component and also from the inner component.

For the sake of an example, whenever the popover is opened, there will be a 5-second timer added to close it.

Given the same configuration in App as the code above (App-2a.vue):

Now there is a declared property (open) which is initially written to isOpen data property and also synced to it via a watcher. This is done because a prop should not be mutated. Then the data property is bound to the inner component.

On the flip side, a custom listener on the inner component will update the isOpen data prop with changes. Then from a local watcher on the data, the update:open event is emitted to the App component.

Now the outer component (HelloPopover) has both a data property that can be updated and a watcher which will fire if either the App or inner component change the value of open.

Now is a good time to point out that the only thing stopping infinite loops here, is that watchers won’t fire when values do not change. So although setting isOpen to true will set open in v-popover and in turn trigger an update:open event which will update isOpen again, the value will be the same and no further action will fire.

Finally, with this in place, we can add behaviour to the isOpen watcher and set up our 5-second wait and close.

Note for Vue 3: proxyListeners will need to be proxyAttrs and the attribute to extract should be onUpdate:open.

Functional Wrapper

The last example had state and watchers, i.e. a lifecycle. But in many cases that won’t be necessary and a functional component could be used to wrapper another component.

Functional templates are generally easiest to read and maintain, but they have some downsides. Firstly, the component to use must be available either globally or in the calling component scope. While ignoring the latter, look at typical use of the v-tooltip library, it is often installed globally, meaning the v-popover component would be available for use. The next problem it has is that there is no mechanism to proxy native listeners to the inner component. They are available in the context under data.nativeOn, but there is no way to bind them as an object.

But if the functional component is written in code, both those problems disappear. In this example, native listeners will be proxied to VPopover via the data object. Also illustrated is how an additional prop could be added in to what is passed to the inner component.

Of course, we still need to work within the limitations of a functional component, without its own lifecycle.

Note for Vue 3: Functional Components may not be the best solution (details).

Headless Components

While not strictly covering our use case of wrapping a specific third-party library, for completeness, a component without a template of its own is another way to extend other components.

Let’s look at some solutions to the auto-close behaviour. While not very elegant, the most obvious solution:

Now there is no need to proxy attributes, properties, or slots from App through to v-popover. Here style and the native events are added directly.

The outer component will render its default slot. With slots, it is not possible to pass data from a parent without wiring it in the slot template. So we cannot auto-wire any bindings or listeners, they need to be provided via the scope in v-slot. Here there are attrs and listeners objects, and the reason for this is to provide some extensibility in the future along with some abstraction now. But it is both verbose and also rigid.

As a slight improvement, instead of:

<HelloPopover :open.sync="open">

the following:

<HelloPopover v-model="open" inner-prop="open">

Where innerProp is a string property used to dynamically change the name of the property to use on the inner component (default slot), so increasing the flexibility of the headless component.

Using v-model here just simplifies the headless component interface, so theopen prop is replaced by value and the event emitted should be input instead of update:open.

Before wrapping up the article, here’s a whackier alternative to finish on.

By sending the headless component as the slot scope, we can wire up :open.sync in the slot template. This is more of an experiment than a real-world solution as sending a component instance through slot scope is against the intent of the feature I believe, not to mention, we’d be writing to the data of the headless component from the slot. For this to work, it is important to understand .sync and also object reactivity, so, for example, destructuring isOpen from the scope won’t work.

Conclusion

The Tapped Props example will probably be the one most used. Functional components may cover some cases, and headless components will cover less again.

--

--

Michael Gallagher
Vue.js Developers

Living in the vibrant city of Buenos Aires, developing eCommerce software with Hip. Modern JS is my passion, Vue my tool of choice