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:
It’s a ride, so buckle up!
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.
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?
My first attempt at doing this was to continue with our named slots theme for two reasons:
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.
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.
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.
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:
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
)addCondition
method injected. This method comes from the If
component and tells it that there’s another condition it should check.shouldRender
ref that we use to toggle the slot of the Else
component.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:
props.val
is true
, we follow that branchelse if
branch that evaluates to true
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.
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 optionconditional: 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 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>
We’ve covered a lot here:
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