Vue.js course Β· Module 8: Slots & Dynamic Components

Advanced Slot Patterns

5 min read
In this lesson5

Now that you know all the tools of the NOVA LAB module storage, let's see how to combine them into advanced patterns - like the station's complete logistics system. Let's start with a problem: the mission clock has to count seconds, and the crew wants to see it once as a digital display and once as a progress bar. The start, pause and reset logic is the same every time; only the look is different.

Renderless Components

A renderless component is a component that renders no HTML of its own - its template contains only <slot>, and it provides logic through a scoped slot. The parent has full control over rendering. We will build one in four stages. First the clock state and the start function, which launches a setInterval that increments the counter every second:

1<!-- MissionTimer.vue (renderless) -->
2<script setup>
3import { ref, onMounted, onUnmounted } from 'vue'
4
5const count = ref(0)
6const isRunning = ref(false)
7let interval = null
8
9const start = () => {
10  if (!isRunning.value) {
11    isRunning.value = true
12    interval = setInterval(() => count.value++, 1000)
13  }
14}

The condition in start prevents a second timer from starting if someone clicks twice. The interval variable is not a ref, because the template doesn't need to display it. Now the pause, the reset and the cleanup on unmount:

1const stop = () => {
2  isRunning.value = false
3  clearInterval(interval)
4}
5
6const reset = () => {
7  count.value = 0
8  stop()
9}
10
11onUnmounted(() => clearInterval(interval))
12</script>

clearInterval stops the timer, and onUnmounted guarantees it won't keep ticking after the component is gone. The third stage is the whole template: a single <slot> that passes the state and the functions as slot props:

1<template>
2  <slot
3    :count="count"
4    :isRunning="isRunning"
5    :start="start"
6    :stop="stop"
7    :reset="reset"
8  />
9</template>

The component adds no element to the page - everything the crew sees will be provided by the parent. Finally, usage with any UI, with the destructuring in v-slot you already know:

1<!-- Usage with any UI -->
2<MissionTimer v-slot="{ count, isRunning, start, stop, reset }">
3  <div class="timer">
4    <h2>{{ count }}s</h2>
5    <button v-if="!isRunning" @click="start">Start</button>
6    <button v-else @click="stop">Pause</button>
7    <button @click="reset">Reset</button>
8  </div>
9</MissionTimer>

A second parent can build a progress bar from the same data, and MissionTimer won't change a line. The Vue documentation notes, however, that most of what renderless components do is handled more efficiently by the Composition API, without an extra level of component nesting - in the next module you will see how to do the same with a composable.

Dynamic Slot Names

Slot names can be dynamic. A directive argument in square brackets is an expression, so the slot that receives the content depends here on the value of activeSection:

1<script setup>
2import { ref } from 'vue'
3const activeSection = ref('header')
4</script>
5
6<template>
7  <StationPanel>
8    <template v-slot:[activeSection]>
9      <p>Dynamic content for {{ activeSection }}</p>
10    </template>
11  </StationPanel>
12</template>

When activeSection changes to 'footer', the same content jumps to the footer. The shorthand version looks like this: #[activeSection].

useSlots()

You already know useSlots() from the lesson on named slots. Here there is one trap, visible in the hasHeader and hasFooter constants:

1<script setup>
2import { useSlots } from 'vue'
3
4const slots = useSlots()
5
6// Check if a slot exists
7const hasHeader = !!slots.header
8const hasFooter = !!slots.footer
9</script>
10
11<template>
12  <div class="panel">
13    <header v-if="slots.header">
14      <slot name="header" />
15    </header>
16    <main>
17      <slot />
18    </main>
19    <footer v-if="slots.footer">
20      <slot name="footer" />
21    </footer>
22  </div>
23</template>

The hasHeader constant is computed once, when the component is created. If the parent adds a header later, for example through v-if, the constant won't change. The slots.header condition in the template is checked on every render, which is why it is the one that controls the display.

Slot Forwarding

An intermediate component can pass slots further down. The loop over $slots creates, in InnerComponent, a slot with the same name for every slot the wrapper received:

1<!-- Wrapper.vue -->
2<template>
3  <InnerComponent>
4    <template v-for="(_, name) in $slots" #[name]="slotProps">
5      <slot :name="name" v-bind="slotProps || {}" />
6    </template>
7  </InnerComponent>
8</template>

v-bind="slotProps || {}" also forwards the props of scoped slots, and the empty object protects against slots without data. The wrapper's parent fills the slots as if it were talking to InnerComponent directly.

When to Use Which Pattern?

The table below gathers all the techniques of this module in one place:

PatternWhen to Use
PropsSimple data (string, number, boolean)
Default slotInjecting arbitrary HTML
Named slotsMultiple sections to fill
Scoped slotsRendering dependent on child's data
RenderlessLogic only, full UI control
Dynamic componentsSwitching between components
TeleportRendering outside the DOM hierarchy
Async + SuspenseLazy loading heavy components

The lessons of this module went from the simplest technique to the most complex: a static template, slots, dynamic components, Teleport and finally async components with Suspense. Also remember the difference between the two kinds of slots: named slots decide where the parent's content goes, while scoped slots let the child pass data into that content.

My advice: when you hesitate between a prop and a slot, ask who should decide about the look. If it's the child, a prop is enough. If it's the parent, give it a slot. In the editor below, a single MissionTimer drives two completely different interfaces: a digital display and a progress bar.

Remember: the best storage module is one that supplies power and data, and leaves the look of the console to the crew.

Code for this lesson: App.vue
1<script setup>
2import MissionTimer from './MissionTimer.vue'
3</script>
4
5<template>
6  <div class="nova-lab">
7    <h1>Renderless Component</h1>
8
9    <h2>Timer variant 1: Digital display</h2>
10    <MissionTimer v-slot="{ count, isRunning, start, stop, reset }">
11      <div class="timer digital">
12        <div class="display">{{ String(count).padStart(4, '0') }}s</div>
13        <div class="controls">
14          <button v-if="!isRunning" @click="start" class="btn green">START</button>
15          <button v-else @click="stop" class="btn orange">PAUSE</button>
16          <button @click="reset" class="btn red">RESET</button>
17        </div>
18      </div>
19    </MissionTimer>
20
21    <h2>Timer variant 2: Progress bar</h2>
22    <MissionTimer v-slot="{ count, isRunning, start, stop, reset }">
23      <div class="timer progress">
24        <div class="bar-container">
25          <div class="bar-fill" :style="{ width: Math.min(count, 60) / 60 * 100 + '%' }"></div>
26        </div>
27        <p>{{ count }}/60 seconds</p>
28        <button @click="isRunning ? stop() : start()" class="btn">
29          {{ isRunning ? 'Pause' : 'Start' }}
30        </button>
31      </div>
32    </MissionTimer>
33  </div>
34</template>
35
36<style scoped>
37.nova-lab {
38  background: #0a0e27;
39  color: #00ff88;
40  min-height: 100vh;
41  padding: 20px;
42  font-family: monospace;
43}
44h1 { color: #00ff88; margin-bottom: 20px; }
45h2 { color: #00b4d8; margin: 20px 0 10px; font-size: 14px; }
46.timer {
47  border: 2px solid #00b4d8;
48  padding: 20px;
49  margin-bottom: 15px;
50  background: rgba(0, 180, 216, 0.05);
51}
52.display {
53  font-size: 36px;
54  color: #00ff88;
55  text-align: center;
56  margin-bottom: 15px;
57  letter-spacing: 4px;
58}
59.controls { display: flex; gap: 10px; justify-content: center; }
60.btn {
61  border: none;
62  padding: 8px 20px;
63  cursor: pointer;
64  font-family: monospace;
65  font-weight: bold;
66  color: #0a0e27;
67}
68.btn.green { background: #00ff88; }
69.btn.orange { background: #ff9900; }
70.btn.red { background: #ff4444; color: white; }
71.btn:not(.green):not(.orange):not(.red) { background: #00b4d8; }
72.bar-container {
73  width: 100%;
74  height: 20px;
75  background: #1a1a2e;
76  border: 1px solid #00b4d8;
77  margin-bottom: 10px;
78}
79.bar-fill {
80  height: 100%;
81  background: linear-gradient(90deg, #00ff88, #00b4d8);
82  transition: width 0.3s;
83}
84</style>

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is a renderless component?

  2. 2. How do scoped slots differ from named slots?

These are 2 of 9 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the dynamic slot name syntax:

  • Code editor

    Create a MissionTimer component that renders no HTML of its own (its template contains only <slot>) and provides count and start/stop/reset through a scoped slot.

  • Vertical ordering

    Order the lifecycle hooks of a component with KeepAlive:

  • Click in order

    Arrange the code for KeepAlive limited to selected components:

  • Horizontal ordering

    Arrange the code for passing data through a scoped slot in the child:

  • Vertical ordering

    Order the rendering techniques from simplest to most advanced:

  • Horizontal ordering

    Arrange the code for a conditional Teleport:

  • Vertical ordering

    Order the stages of loading an async component with full configuration:

  • Click in order

    Arrange the code for defineAsyncComponent with loadingComponent and errorComponent:

  • Horizontal ordering

    Arrange the KeepAlive syntax with a cache limit:

  • Vertical ordering

    Order what happens when switching a dynamic component WITHOUT KeepAlive:

Useful articles