Slots and Scoped Slots in Vue.js
Slots allows us to reuse and control presentation of a UI component in a simple way. Most common use case of slots is delegating “what” to render to the parent component (or the component using the slot) and adding to that, scoped-slots allow us to pass back data to the parent component to help it render the “what”.
Why would I need slots?
Let’s say we have a Custom Submit button used in our form and it has two states
based on the form. An initial state where Button is active with ‘Submit’ text
and a Loading state where Button is disabled and showing ‘Loading…” text.
Typically, change of button state based on the form is managed in the component
that holds the form state. So it makes sense to just pass props to our Custom
Submit button whether it should show active or disabled states. This is how our
CustomSubmitButton
would look like:
/* CustomSubmitButton.vue */
<script setup lang="ts">
withDefaults(defineProps<{ loading: boolean }>(), {
loading: false,
});
/* Other CustomSubmitButton related Logic, Analytic events etc here.. */
</script>
<template>
<div class="wrapper">
<button class="submit-btn">{{ loading ? "Loading..." : "Submit" }}</button>
</div>
</template>
/* MyForm.vue */
<template>
<form>
<!-- -->
<CustomButton />
</form>
</template>
It’s all well and good until you have another form where you’d like to use this
CustomSubmitButton
but the marketing team said they want to try out a
different text for that button instead of ‘Submit’. So you’re probably thinking,
that’s fine I can just pass whatever text the button should show as props
and
it’ll be fine.
/* CustomSubmitButton.vue */
<script setup lang="ts">
withDefaults(defineProps<{ loading: boolean; text: string }>(), {
loading: false,
text: "Submit",
});
</script>
<template>
<div class="wrapper">
<button>{{ loading ? "Loading..." : text }}</button>
</div>
</template>
/* MyForm.vue */
<template>
<form>
<!-- Other form fields -->
<CustomButton text="Register" />
</form>
</template>
After a while, marketing team comes back to you and say we want to change the
“loading” text too. You can probably add another prop called loadingText
to
the custom button and get away again. But what happens when you have yet another
form using this CustomSubmitButton
where you’d like to show a Spinner
Component instead of “Loading…” text?
We can solve this problem using slots by delegating “what” to render to the View/Parent using the component. You can think of slots as the literal meaning, “slot”. We make a space or a slot in the component that gets reused and let the parent fill in that slot. Vue.js comes with a special tag for declaring slots:
<slot></slot>
In our CustomButton
component we can replace the dynamic part (“Loading…”
and “Submit” texts) into a <slot></slot>
.
/* CustomSubmitButton.vue */
<template>
<div class="wrapper">
<button class="submit-btn">
<slot></slot>
<!-- 👈 this part gets replaced by whatever was passed by the parent -->
</button>
</div>
</template>
/* MyForm.vue */
<template>
<form>
<!-- Other form fields -->
<CustomButton> Register </CustomButton>
</form>
</template>
Now since the form state is managed by the Parent Form component, we can choose what to show in the CustomButton slot based on the state within the Parent Form component. So let’s add the Loading Spinner into the slot as well.
/* MyForm.vue */
<template>
<form>
<!-- Other form fields -->
<CustomButton>
<!-- Can be replaced by a loading component -->
<template v-if="loading">Loading...</template>
<template v-else>Register</template>
</CustomButton>
</form>
</template>
Scoped Slots
Slots also provide a very powerful feature of passing value(s) back up to the
parent component. Let’s say we want to access the focus or hover state (or any
internal state) of the CustomButton
component in the form, we can do so by
binding the property onto the <slot>
component.
/* CustomSubmitButton.vue */
<script setup lang="ts">
import { ref } from "vue";
/* --- */
const isFocused = ref<boolean>(false);
</script>
<template>
<div class="wrapper">
<button
@focusout="isFocused = false"
@focusin="isFocused = true"
class="submit-btn"
>
<slot v-bind:isFocused="isFocused"></slot>
</button>
</div>
</template>
/* MyForm.vue */
<template>
<form>
<!-- Other form fields -->
<CustomButton v-slot="{ isFocused }">
<!-- Do whatever you want with `isFocused` value in the parent template -->
{{ isFocused }}
<!-- Can be replaced by a loading component -->
<template v-if="loading">Loading...</template>
<template v-else>Register</template>
</CustomButton>
</form>
</template>
We can also wrap all the form related logic into a its own renderless component which handles submitting form, error states, form field states and expose them through scoped slots.
/* MyForm.vue */
<template>
<MyCustomFormWrapper v-slot="{ errors, submit, fields }">
<!-- Other form fields -->
<span v-if="errors.email" class="error">{{ errors.email }}</span>
<CustomButton @click="submit" v-slot="{ isFocused }">
<!-- Do whatever you want with `isFocused` state in the parent template -->
{{ isFocused }}
<!-- Can be replaced by a loading component -->
<template v-if="loading">Loading...</template>
<template v-else>Register</template>
</CustomButton>
</MyCustomFormWrapper>
</template>
This pattern is pretty common in libraries like VueUse, vee-validate, Headless UI and many more, since it allows us to build highly reusable renderless components.
Further Reading / References: