Advanced Vue: Controlling Parent Slots (Case Study)

michaelthiessen

Michael Thiessen

Posted on August 16, 2019

Advanced Vue: Controlling Parent Slots (Case Study)

Let me ask you about something you've probably never thought about:

Is there a way to populate a parent's slot from a child component?

Recently a coworker asked me this, and the short answer is:

Yes.

But the solution I arrived at is probably very different from what you're thinking right now.

You see, my first approach turned out to be a terrible idea, and it took me a few attempts before I figured out what I think is the best approach to this problem.

It's a thorny Vue architecture problem, but also a very interesting one.

In this article we'll go through each of these solutions one by one, and see why I think they aren't that great. Ultimately we'll land on the best solution at the end.

But why did we have this problem in the first place?

Why this obscure problem?

In our application we have a top bar that contains different buttons, a search bar, and some other controls.

It can be slightly different depending on which page you're on, so we need a way of configuring it on a per page basis.

To do this, we want each page to be able to configure the action bar.

Seems straightforward, but here's the catch:

This top bar (which we call an ActionBar) is actually part of our main layout scaffolding, which looks like this:

<template>
  <div>
    <FullPageError />
    <ActionBar />
    <App />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Where App is dynamically injected based on the page/route you're on.

There are some slots that ActionBar has that we can use to configure it. But how can we control those slots from the App component?

Defining the Problem

First it's a good idea to be as clear as we can about what exactly we are trying to solve.

Let's take a component that has one child component and a slot:

// Parent.vue
<template>
  <div>
    <Child />
    <slot />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

We can populate the slot of Parent like this:

// App.vue
<template>
  <Parent>
    <p>This content goes into the slot</p>
  </Parent>
</template>
Enter fullscreen mode Exit fullscreen mode

Nothing too fancy here...

Populating the slot of a child component is easy, that's how slots are usually used.

But is there a way that we can control what goes into the slot of the Parent component from inside of our Child component?

Stated more generally:

Can we get a child component to populate the slots of a parent component?

Let's take a look at the first solution I came up with.

Props down, events up

My initial reaction to this problem was with a mantra that I keep coming back to:

Props down, events up

The only way data flows down through your component tree is through using props. And the only way you communicate back up the tree is by emitting events.

This means that if we need to communicate from a child to a parent, we use events for that.

So we'll use events to pass content into the ActionBars slots!

In each application component we'll need to do the following:

import SlotContent from './SlotContent';

export default {
  name: 'Application',
  created() {
    // As soon as this component is created we'll emit our events
    this.$emit('slot-content', SlotContent);
  }
};
Enter fullscreen mode Exit fullscreen mode

We package up whatever we want to put in the slot into a SlotContent component (the name is unimportant). As soon as the application component is created, we emit the slot-content event, passing along the component we want to use.

Our scaffold component would then look like this:

<template>
  <div>
    <FullPageError />
    <ActionBar>
      <Component :is="slotContent" />
    </ActionBar>
    <App @slot-content="component => slotContent = component" />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

It will listen for that event, and set slotContent to whatever our App component sent us. Then, using the built-in Component, we can render that component dynamically.

Passing around components with events feels weird though, because it's not really something that "happens" in our app. It's just part of the way the app was designed.

Luckily there's a way we can avoid using events altogether.

Looking for other $options

Since Vue components are just Javascript objects, we can add whatever properties we want to them.

Instead of passing the slot content using events, we can just add it as a field to our component:

import SlotContent from './SlotContent';

export default {
  name: 'Application',
  slotContent: SlotContent,
  props: { /***/ },
  computed: { /***/ },
};
Enter fullscreen mode Exit fullscreen mode

We'll have to slightly change how we access this component in our scaffolding:

<template>
  <div>
    <FullPageError />
    <ActionBar>
      <Component :is="slotContent" />
    </ActionBar>
    <App />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode
import App from './App';
import FullPageError from './FullPageError';
import ActionBar from './ActionBar';

export default {
  name: 'Scaffold',
  components: {
    App,
    FullPageError,
    ActionBar,
  }
  data() {
    return {
      slotContent: App.slotContent,
    }
  },
};
Enter fullscreen mode Exit fullscreen mode

This is more like static configuration, which is a lot nicer and cleaner 👌

But this still isn't right.

Ideally, we wouldn't be mixing paradigms in our code, and everything would be done declaratively.

But here, instead of taking our components and composing them together, we're passing them around as Javascript objects.

It would be nice if we could just write what we wanted to appear in the slot in a normal Vue way.

Thinking in portals

This is where portals come in.

And they work exactly like you would expect them to. You're able to teleport anything from one location to another. In our case, we're "teleporting" elements from one location in the DOM somewhere else.

We're able to control where a component is rendered in the DOM, regardless of what the component tree looks like.

For example, let's say we wanted to populate a modal. But our modal has to be rendered at the root of the page so we can have it overlay properly. First we would specify what we want in the modal:

<template>
  <div>
    <!-- Other components -->
    <Portal to="modal">
      Rendered in the modal.
    </Portal>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Then in our modal component we would have another portal that would render that content out:

<template>
  <div>
    <h1>Modal</h1>
    <Portal from="modal" />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

This is certainly an improvement, because now we're actually writing HTML instead of just passing objects around. It's far more declarative and it's easier to see what's going on in the app.

Except that in some ways it isn't easier to see what's going on.

Because portals are doing some magic under the hood to render elements in different places, it completely breaks the model of how DOM rendering works in Vue. It looks like you're rendering elements normally, but it's not working normally at all. This is likely to cause lots of confusion and frustration.

There's another huge issue with this, but we'll cover that later on.

At least with adding the component to the $options property, it's clear that you're doing something different.

I think there's a better way still.

Lifting state

"Lifting state" is a term that's thrown around the front end development circles a bit.

All it means is that you move state from a child component to a parent, or grandparent component. You move it up the component tree.

This can have profound effects on the architecture of your application. And for our purposes, it actually opens up a completely different — and simpler — solution.

Our "state" here is the content that we are trying to pass into the slot of the ActionBar component.

But that state is contained within the Page component, and we can't really move page specific logic into the layout component. Our state has to stay within that Page component that we're dynamically rendering.

So we'll have to lift the whole Page component in order to lift the state.

Currently our Page component is a child of the Layout component:

<template>
  <div>
    <FullPageError />
    <ActionBar />
    <Page />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Lifting it would require us to flip that around, and make the Layout component a child of the Page component. Our Page component would look something like this:

<template>
  <Layout>
    <!-- Page-specific content -->
  </Layout>
</template>
Enter fullscreen mode Exit fullscreen mode

And our Layout component would now look something like this, where we can just use a slot to insert the page content:

<template>
  <div>
    <FullPageError />
    <ActionBar />
    <slot />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

But this doesn't let us customize anything just yet. We'll have to add some named slots into our Layout component so we can pass in the content that should be placed into the ActionBar.

The most straightforward way to do this would be to have a slot that replaces the ActionBar component completely:

<template>
  <div>
    <FullPageError />
    <slot name="actionbar">
      <ActionBar />
    </slot>
    <slot />
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

This way, if you don't specify the "actionbar" slot, we get the default ActionBar component. But you can still override this slot with your own custom ActionBar configuration:

<template>
  <Layout>
    <template #actionbar>
      <ActionBar>
        <!-- Custom content that goes into the action bar -->
      </ActionBar>
    </template>
    <!-- Page-specific content -->
  </Layout>
</template>
Enter fullscreen mode Exit fullscreen mode

To me, this is the ideal way of doing things, but it does require you to refactor how you lay out your pages. That could be a huge undertaking depending on how your app is built.

If you can't do this method, my next preferred method would probably #2, using the $options property. It's the cleanest, and most likely to be understood by anyone reading the code.

We can make this simpler

When we first defined the problem we stated it in it's more general form as this:

Can we get a child component to populate the slots of a parent component?

But really, this problem has nothing to do with props specifically. More simply, it's about getting a child component to control what is rendered outside of it's own subtree.

In it's most general form, we would state the problem as this:

What is the best way for a component to control what is rendered outside of it's subtree?

Examining each of our proposed solutions through this lens gives us an interesting new perspective.

Emitting events up to a parent

Because our component can't directly influence what happens outside of it's subtree, we instead find a component whose subtree contains the target element we are trying to control.

Then we ask it nicely to change it for us.

Static configuration

Instead of actively asking another component to do something on our behalf, we simply make the necessary information available to other components.

Portals

You may be noticing a pattern here among these first 3 methods.

So let me make this assertion:

There is no way for a component to control something outside of it's subtree.

(proving it is left as an exercise to the reader)

So each method here is a different way to get another component to do our bidding, and control the element that we are actually interested in.

The reason that portals are nicer in this regard is that they allow us to encapsulate all of this communication logic into separate components.

Lifting State

This is where things really start to change, and why lifting state is a simpler and more powerful technique than the first 3 we looked at.

Our main limitation here is that what we want to control is outside of our subtree.

The simplest solution to that:

Move the target element into our subtree so we can control it!

Lifting state — along with the logic to manipulate that state — allows us to have a larger subtree and to have our target element contained within that subtree.

If you can do this, it's the simplest way to solve this specific problem, as well as a whole class of related problems.

Keep in mind, this doesn't necessarily mean lifting the entire component. You can also refactor your application to move a piece of logic into a component higher up in the tree.

It's really just dependency injection

Some of you who are more familiar with software engineering design patterns may have noticed that what we're doing here is dependency injection — a technique we've been using for decades in software engineering.

One of it's uses is in making code that is easy to configure. In our case, we're configuring the Layout component differently in each Page that uses it.

When we flipped the Page and Layout components around, we were doing what is called an inversion of control.

In component-based frameworks the parent component controls what the child does (because it is within it's subtree), so instead of having the Layout component controlling the Page, we chose to have the Page control the Layout component.

In order to do this, we supply the Layout component what it needs to get the job done using slots.

As we've seen, using dependency injection has the effect of making our code a lot more modular and easier to configure.

Conclusion

We went through 4 different ways of solving this problem, showing the pros and cons of each solution. Then we went a little further and transformed the problem into a more general one of controlling something outside of a component's subtree.

I hope that you'll see that lifting state and dependency injection are two very useful patterns to use. They are wonderful tools for you to have in your arsenal, as they can be applied to a myriad of software development problems.

But above all, I hope you take this away:

By using some common software patterns we were able to turn a problem that only had ugly solutions into a problem that had a very elegant one.

Many other problems can be attacked in this way — by taking an ugly, complicated problem and transforming it into a simpler, easier to solve problem.

If you want some more advanced content on slots, I replicated the v-for directive, showing how to use nested slots and nested scoped slots recursively. It's one of my favourite articles, so do check it out!

💖 💪 🙅 🚩
michaelthiessen
Michael Thiessen

Posted on August 16, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related