Building a (Totally) Unnecessary If/Else Component in Vue

I decided to do something a little weird, and maybe you’ll think I’ve gone (slightly) insane.

I wanted to see what it would look like to make an If...Else component.

Yeah, I know we have v-if, v-else and v-else-if, but that’s not the point.

This is where I ended up:

<If :val="mainCondition">
<template #true>Render if true</template>
<Else :if="false">Else if condition</Else>
<template #false>Otherwise render this</template>
</If>

But you could refactor it to this slightly cleaner API if you want to experiment with it:

<If :val="condition">
<True>Truth</True>
<Else :if="elseCondition">Else slot</Else>
<False> What up false branch! </False>
</If>

You can find a demo of the component here if you want to play around with it: https://stackblitz.com/edit/vue-kgfeqk?file=src%2FApp.vue

Let me take you on my journey of figuring this one out. Along the way we’ll cover:

  • Compound Components
  • Default slots, named slots
  • Render functions
  • Defining custom component options
  • Callback functions with reactive objects

It’s a ride, so buckle up!

Handling the Truth (and the False)

The most basic case is pretty easy. All we need is a single prop and a single v-if that checks that prop:

<template>
<slot v-if="val" />
</template>

With a simple script:

export default {
props: ['val'],
setup() {},
};

We’re able to use it like this:

<If :val="putConditionHere">
Renders only if it's true
</If>

We need to add a second slot if we also want a false branch in the component. Let’s create true and false named slots:

<template>
<slot v-if="val" name="true" />
<slot v-else name="false" />
</template>

Now, we can have both branches of our conditional! This is great:

<If :val="putConditionHere">
<template #true>
Renders only if it's true
</template>
<template #false>
FALSE
</template>
</If>

But, we’ve lost the simplicity of the default slot if we only need one condition.

Handling default and named slots together

Let’s add that back in so we can use it like we were before:

<template>
<slot v-if="val" />
<template v-if="!$slots.default">
<slot v-if="val" name="true" />
<slot v-if="!val" name="false" />
</template>
</template>

This time, you’ll notice we use a template to group the named slots. We’re also checking $slots to see if we’ve put anything in the default slot or not.

We need to do this, otherwise we’ll render the default and true slots whenever our condition is true, which isn’t what we want! We want these to be mutually exclusive — you can either use the default slot or the named true slot.

Now, we can use it in either way.

Just the default slot:

<If :val="putConditionHere">
Renders only if it's true
</If>

Or using the named slots to access both branches:

<If :val="putConditionHere">
<template #true>
Renders only if it's true
</template>
<template #false>
FALSE
</template>
</If>

We now have a pretty basic If...Else component, and it wasn’t all that difficult. A few tricks with slots, but nothing too crazy.

But it’s also not that interesting, so we’re not stopping there.

How can we add in support for ElseIf branches? And how can we support multiple of them?

ElseIf: Attempt #1 — Figuring out the wrong way to do it

My first attempt at doing this was to continue with our named slots theme for two reasons:

  • It keeps our API consistent, since we’re just defining named slots
  • It also keeps our component consistent

I like consistency, so I wanted something that would work like this:

<If :val="putConditionHere">
<template #true>
Renders only if it's true
</template>
<template #elseif>
Else if branch
</template>
<template #false>
FALSE
</template>
</If>

This is nice and elegant, but we’re missing the ability to pass in the condition. We could just add it as a prop:

<If :val="putConditionHere" :else-if="condition">
<template #true>
Renders only if it's true
</template>
<template #elseif>
Else if branch
</template>
<template #false>
FALSE
</template>
</If>

This feels awful though.

The code for the branch and the condition are now separated, and this just doesn’t seem right.

Maybe we could do some trickery with scoped slots? I’m always up for doing hacky things with slots — just don’t tell Evan:

<If :val="putConditionHere">
<template #true>
Renders only if it's true
</template>
<template #elseif="{ condition }">
Else if branch
</template>
<template #false>
FALSE
</template>
</If>

Okay, so the syntax looks better. But there’s a pretty major issue here.

It doesn’t actually work

Scoped slots work by passing data from the child to the parent, not from the parent to the child. So somehow we’ve got to go backwards.

However, we can go “backwards” by passing in a callback function. Calling the function with the right value sends the data in the other direction. This is essentially how events work in React, since they only have props and no special syntax for communicating up the component tree.

So in our If component we’ll pass in a callback function:

<template>
<slot v-if="val" />
<template v-if="!$slots.default">
<slot name="elseif" :condition="elseIfCallback">
<!-- Ignore these for now, we'll figure this out soon -->
<slot v-if="val" name="true" />
<slot v-if="!val" name="false" />
</template>
</template>

Here are a few questions we now face:

What, exactly, is this callback supposed to do?

Perhaps more importantly, how on earth do we call the callback from the elseif slot?

You see, we’re trying to control when that slot is rendered based on the callback. But the callback is only passed to the parent when the slot is rendered. We’re stuck.

There’s no point in addressing the first question now, since this is clearly a terrible idea.

Using Compound Components

If we can’t use slots for this, then we can use the next best thing — Compound Components.

Let me take you on quick detour before we get back to solving our problem.

Compound Components are a set of related and highly coupled components that you can assemble to do whatever you need. They share state tightly, typically using provide and inject, but you could use a composable for this as well depending on your use case.

One common use case is for forms.

Imagine you take a giant FormThatDoesEverything component, but you want to make it more modular and reusable. A big problem you’ll run into is passing state between these components, and you’d end up with something like this:

<Form v-slot="{ formState }">
<TextControl :form-state="formState" required />
<TextControl :form-state="formState" required />
<TextControl :form-state="formState" />
<TextControl :form-state="formState" required />
<TextControl :form-state="formState" />
</Form>

We need to pass state between these components like this because they are tightly coupled. The alternative here would be to keep it as one big component, but we’ve already decided we don’t want that.

But we also don’t want to pass lots of props around, as this is just tons of boiler-plate and very error-prone.

Instead, we can use the Compound Component Pattern to improve the API of these form components:

<Form>
<TextControl required />
<TextControl required />
<TextControl />
<TextControl required />
<TextControl />
</Form>

Let’s apply this pattern to our components now.

Attempt #2: Getting it to actually work

Here’s the final (but still extremely experimental, of course) API that I came up with:

<template>
<If :val="condition">
<template #true>Truth</template>
<Else :if="elseCondition">Else slot</Else>
<template #false> What up false branch! </template>
</If>
</template>

If we were really a stickler for consistent API usage, we could write similar True and False components so we don’t have to mix named slots with our compound components, but I haven’t done that here:

<template>
<If :val="condition">
<True>Truth</True>
<Else :if="elseCondition">Else slot</Else>
<False> What up false branch! </False>
</If>
</template>

Actually, that’s so much better. Maybe I should refactor it to work this way…

Now I need to explain how these components work.

Here’s the Else compound component:

<template>
<slot v-if="shouldRender" />
</template>
import { ref, reactive, inject, watchEffect } from 'vue';
export default {
props: ['if'],
conditional: true,
setup(props) {
const addCondition = inject('addCondition');
const shouldRender = ref(false);
const condition = props.if ?? false;
const obj = reactive({
condition,
shouldRender,
});
addCondition(obj);
return { shouldRender };
},
};

There are a few key things going on here:

  1. We use an if prop to get the conditional we’re evaluating (as condition, defaulting to false if this is just an else and not an else if)
  2. We get an addCondition method injected. This method comes from the If component and tells it that there’s another condition it should check.
  3. We create a shouldRender ref that we use to toggle the slot of the Else component.
  4. Then we make a reactive object with the condition and shouldRender, passing the whole thing up to the If component using the addCondition method.

Injecting a callback function like this is a pretty typical pattern within the Compound Component pattern. The parent component often needs to know some information about it’s descendants (children, grandchildren, etc.), and a function like this allows us to “register” the descendant with the main parent component.

Next, we’ll look at the If component, piece by piece.

First, we create an array to track all of the conditions that we have to evaluate. We’ll also provide the addCondition method which just adds to this array:

const conditions = reactive([]);
provide('addCondition', (cond) => {
conditions.push(cond);
});

The second step is to evaluate which of these Else branches we should render (if any), since we’ll be able to have multiple:

const elseBranch = ref(false);
watch(
conditions,
() => {
elseBranch.value = false;
if (!props.val) {
const branch = conditions.find((br) => br.condition === true);
if (branch) {
branch.shouldRender = true;
elseBranch.value = true;
}
}
},
{ deep: true, immediate: true }
);

We use the elseBranch flag to track whether we should render an Else component, or if we can just render the true or false named slots of the If component.

The logic in this watcher is exactly how an if...else if...else branch works. We do the following:

  1. If props.val is true, we follow that branch
  2. Otherwise, we follow the first else if branch that evaluates to true
  3. We fallback on the final else branch if nothing is true

If we do find an Else branch that evaluates to true, we set it’s shouldRender to true, which causes it to render itself through the v-if in the Else component’s default slot:

<!-- Else.vue -->
<template>
<slot v-if="shouldRender" />
</template>

Now, we have all of our logic working great. But we aren’t yet rendering anything in this If component.

Implementing the render function

Here we’ll be using a render function, since we need the added flexibility — we’ll get to that part in a moment:

return () => {
const slots = useSlots();
const children = [];
if (props.val && slots.true) {
children.push(slots.true());
}
return children;
};

We’ll use a children array to collect all of the elements to render. In this code snippet we’re just tackling the most basic case — rendering the true slot when props.val evaluates to true.

To render the false slot, we modify our conditional slightly:

return () => {
const slots = useSlots();
const children = [];
if (props.val && slots.true) {
children.push(slots.true());
} else if (!props.val && slots.false) {
children.push(slots.false());
}
return children;
};

In both cases we’re first checking to make sure that the named slot actually exists. This is important, because otherwise we’ll crash the component if we try to execute the slot otherwise!

Now we need to deal with the Else components.

Remember, we need these components to always be rendered, because they need to be mounted in order for them to evaluate their conditionals:

const slots = useSlots();
const conditionalComponents = slots.default()
const children = [conditionalComponents];

But this code isn’t quite correct yet.

We don’t want to render all components in the default slot, just the Else components. We can do this by adding a custom component option to our Else component:

export default {
props: ['if'],
// 👇 Custom component option
conditional: true,
setup(props) {
// ...
},
};

You can also use defineOptions for this if you’re using script setup.

We can then filter out all of our components that match this custom option in our If component render function:

const slots = useSlots();
const conditionalComponents = slots
.default()
.filter((el) => el.type.conditional);
const children = [conditionalComponents];

Now we’ll only render the Else components by default. If you wanted, you can now extend the If component to render the rest of the default slot if props.val evaluates to true, but I haven’t done that here.

One last thing — we need to make sure we don’t render the false slot if we’re rendering an Else branch. That’s where that elseBranch flag comes in:

if (props.val && slots.true) {
children.push(slots.true());
} else if (!props.val && !elseBranch.value && slots.false) {
children.push(slots.false());
}

The Final Solution

The full If component looks like this:

import { ref, reactive, watch, provide, useSlots } from 'vue';
export default {
props: ['val'],
setup(props) {
const conditions = reactive([]);
const elseBranch = ref(false);
provide('addCondition', (cond) => {
conditions.push(cond);
});
watch(
conditions,
() => {
elseBranch.value = false;
if (!props.val) {
const branch = conditions.find((br) => br.condition === true);
if (branch) {
branch.shouldRender = true;
elseBranch.value = true;
}
}
},
{ deep: true, immediate: true }
);
return () => {
const slots = useSlots();
const conditionalComponents = slots
.default()
.filter((el) => el.type.conditional);
const children = [conditionalComponents];
if (props.val && slots.true) {
children.push(slots.true());
} else if (!props.val && !elseBranch.value && slots.false) {
children.push(slots.false());
}
return children;
};
},

The full Else component:

<template>
<slot v-if="shouldRender" />
</template>
import { ref, reactive, inject, watchEffect, watch } from 'vue';
export default {
props: ['if'],
conditional: true,
setup(props) {
const addCondition = inject('addCondition');
const shouldRender = ref(false);
const condition = ref(props.if);
const obj = ref({
condition: props.if,
shouldRender,
});
watchEffect(() => console.log('prop', props.if));
watch(
() => obj,
() => console.log(obj.condition),
{
deep: true,
immediate: true,
}
);
addCondition(obj);
return { shouldRender };
},
};

And one last time, here’s how we’d use the compound component to achieve a If...Else component:

<template>
<If :val="false">
<template #true>Truth</template>
<Else :if="elseCondition">Else slot</Else>
<template #false> What up falsity </template>
</If>
</template>

Summary

We’ve covered a lot here:

  • Compound Components
  • Default slots, named slots
  • Render functions
  • Defining custom component options
  • Callback functions with reactive objects

All of that just to end up with a useless component that no one will ever use!

But pushing yourself to experiment and do “weird” things is one of the best ways I’ve found to really learn — as we’ve seen throughout this article.

Here’s the demo StackBlitz if you wanted to try implementing the improved API for yourself, or just want to check it out: https://stackblitz.com/edit/vue-kgfeqk?file=src%2FApp.vue