Vue Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Slots, Provide/Inject & Render Functions

Flexible component APIs and dependency injection.

Props and emits cover most communication between components, but they force a rigid shape: the parent can only pass data, and the child decides exactly how it's rendered. Reusable component libraries, the kind you'd build for a design system, need more flexibility than that:

  • Slots let the parent pass template content into a child.
  • Scoped slots let the child pass data back to the content the parent provided.
  • Provide / inject shares values with any descendant without threading props through every level.
  • Render functions (h()) give you full JavaScript control when templates aren't expressive enough.
  • Async components and <KeepAlive> control when components load and whether they stay alive.

Slots recap

Props & Emits introduced default slots, named slots (#header), fallback content and $slots checks. Two rules from there matter for everything below:

  • A slot is a placeholder in the child that the parent fills with template content.
  • Slot content is compiled in the parent's scope, so it can use the parent's data but not the child's. Giving the parent access to the child's data is exactly what scoped slots add.

Scoped slots: the child passes data back

A scoped slot is how a component can own the logic while the parent owns the markup. The child binds attributes on <slot>, and the parent receives them as slot props:

<!-- TaskList.vue: owns filtering, sorting and empty state -->
<script setup>
import { computed } from 'vue';
const props = defineProps({ tasks: Array, sortBy: { type: String, default: 'priority' } });

const order = { high: 0, medium: 1, low: 2 };
const sorted = computed(() =>
    [...props.tasks].sort((a, b) => order[a.priority] - order[b.priority]));
</script>

<template>
    <ul v-if="sorted.length">
        <li v-for="(task, index) in sorted" :key="task.id">
            <slot :task="task" :index="index" :is-urgent="task.priority === 'high'">
                {{ task.title }}  <!-- fallback rendering -->
            </slot>
        </li>
    </ul>
    <slot v-else name="empty"><p>No tasks.</p></slot>
</template>
<!-- Parent decides how each row looks -->
<TaskList :tasks="tasks">
    <template #default="{ task, index, isUrgent }">
        <span>{{ index + 1 }}.</span>
        <strong :class="{ urgent: isUrgent }">{{ task.title }}</strong>
    </template>
    <template #empty>
        <EmptyState icon="🎉" message="All clear!" />
    </template>
</TaskList>

The same TaskList can render a compact list, a card grid or a table row without changing a line of its logic. Switch layouts in the demo. One TaskList component, with its sorting and empty state, drives all three:

Renderless components. Take scoped slots to the extreme and a component renders nothing of its own. It just exposes state and functions through its default slot, e.g. <FetchData url="..." v-slot="{ data, loading }"> or <Toggle v-slot="{ on, toggle }">. In Vue 3, composables usually do this job more simply, but renderless components are still useful when the logic needs to wrap template structure, like a headless dropdown or a virtual list.

Provide / inject: dependency injection for components

Some values are needed deep in the tree: the current user, a theme, a form context, a locale. Passing them as props through every intermediate component is prop drilling. provide makes a value available to all descendants, and inject reads it at any depth.

BoardLayout provide(BoardKey, board) Column Column TaskCard inject → TaskMenu inject → Badge Outside tree: gets default skips levels
// keys.js: a typed, collision-proof injection key
import type { InjectionKey, Ref } from 'vue';

export interface BoardContext {
    theme: Ref<'light' | 'dark'>;
    toggleTheme: () => void;
    moveTask: (taskId: number, column: string) => void;
}

export const BoardKey: InjectionKey<BoardContext> = Symbol('board');
<!-- BoardLayout.vue (ancestor) -->
<script setup lang="ts">
import { ref, provide, readonly } from 'vue';
import { BoardKey } from './keys';

const theme = ref<'light' | 'dark'>('light');

provide(BoardKey, {
    theme: readonly(theme),     // descendants can read but not overwrite
    toggleTheme: () => { theme.value = theme.value === 'light' ? 'dark' : 'light'; },
    moveTask: (id, column) => { /* … */ },
});
</script>
<!-- PriorityBadge.vue (any depth below) -->
<script setup lang="ts">
import { inject } from 'vue';
import { BoardKey } from './keys';

const board = inject(BoardKey);             // typed as BoardContext | undefined
if (!board) throw new Error('PriorityBadge must be used inside <BoardLayout>');
</script>

<template>
    <span :class="['badge', board.theme.value]">…</span>
</template>

Best practices:

  • Provide refs, not raw values. provide('theme', theme) stays reactive. provide('theme', theme.value) passes a frozen snapshot.
  • Wrap in readonly() and provide functions for mutation, so changes flow through the owner (one-way data flow, as with props).
  • Use Symbol / InjectionKey instead of string keys to avoid collisions and get type inference.
  • Give a default for optional dependencies: inject(ThemeKey, ref('light')).
  • app.provide(...) makes a value available app-wide. Plugins such as Vue Router and Pinia use exactly this mechanism internally.

In the demo, only the top component provides the context. The deeply nested badges and the menu read it with inject and toggle it through the provided function. The middle "Column" components never mention it.

Render functions with h()

Templates compile to render functions. Occasionally it's clearer to write one yourself, typically when the element type or structure is computed from data:

import { h } from 'vue';

// <Heading :level="2">Sprint 12</Heading> → <h2>Sprint 12</h2>
export default {
    props: { level: { type: Number, required: true } },
    setup(props, { slots }) {
        return () => h(`h${props.level}`, { class: 'heading' }, slots.default?.());
    },
};

h(type, props, children):

  • type: a tag name ('div') or a component object.
  • props: attributes, DOM props and listeners ({ onClick: fn }). Listeners are on + event name.
  • children: a string, an array of vnodes, or, for components, an object of slot functions.
// Passing slots to a component from a render function
h(TaskList, { tasks }, {
    default: ({ task }) => h('strong', task.title),
    empty: () => h('p', 'Nothing to do'),
});

The same thing in JSX (with @vitejs/plugin-vue-jsx) reads more naturally to React developers:

setup(props, { slots }) {
    const Tag = `h${props.level}`;
    return () => <Tag class="heading">{slots.default?.()}</Tag>;
}

The demo's dynamic heading and recursive tree are written entirely with h(). There's no template at all:

Use sparingly. Templates get compile-time optimisations (static hoisting, patch flags) that hand-written render functions don't. Reach for h() in library code and genuinely dynamic structures, not as your default.

Async components and <KeepAlive>

defineAsyncComponent

Split rarely used, heavy components into separate chunks, downloaded only when first rendered:

import { defineAsyncComponent } from 'vue';

const ReportsChart = defineAsyncComponent({
    loader: () => import('./ReportsChart.vue'),   // separate JS chunk
    loadingComponent: ChartSkeleton,
    errorComponent: ChartError,
    delay: 200,       // show loader only if loading takes > 200ms (avoids flicker)
    timeout: 10000,
});

<KeepAlive>

By default, switching between components with <component :is> destroys the old one, losing its state (form input, scroll position, fetched data). <KeepAlive> caches the instances instead:

<KeepAlive :include="['TaskFilters', 'TaskSearch']" :max="5">
    <component :is="currentTab" />
</KeepAlive>

Cached components receive onActivated / onDeactivated instead of being mounted and unmounted again, which is handy for pausing polling while hidden. Type into each tab below, switch tabs and come back, with and without KeepAlive:

<Teleport>: render somewhere else in the DOM

Modals, toasts and dropdown menus belong to a component logically, since they use its state and events. Visually, though, they need to escape it. An ancestor with overflow: hidden, transform or its own z-index stacking context can clip a modal or trap it underneath other content. <Teleport> keeps the component tree as it is but moves the rendered DOM to another target:

<script setup>
import { ref } from 'vue';
const open = ref(false);
</script>

<template>
    <button @click="open = true">Delete project</button>

    <Teleport to="body">
        <div v-if="open" class="modal-backdrop" @click.self="open = false">
            <div class="modal" role="dialog" aria-modal="true">
                <p>Delete this project and all its tasks?</p>
                <button @click="open = false">Cancel</button>
            </div>
        </div>
    </Teleport>
</template>

Props, emits, provide/inject and reactivity all still work, because only the DOM position changes, not the component hierarchy. :disabled="isMobile" renders the content in place instead, and defer (Vue 3.5+) lets the target be rendered later in the same tick.

The box below has overflow: hidden. The left button renders its modal inline, so it gets clipped. The right one teleports to <body> and escapes:

Key takeaways

  • Slots pass template content into a component. Named slots and $slots checks give you flexible layouts with sensible fallbacks.
  • Scoped slots split logic (child) from presentation (parent). This is the foundation of reusable list, table and "renderless" components.
  • Provide/inject shares context with any descendant. Provide readonly refs plus mutation functions, use InjectionKey symbols, and supply defaults.
  • h() render functions handle structures templates can't express cleanly. Prefer templates otherwise, because they're better optimised.
  • defineAsyncComponent code-splits heavy components. <KeepAlive> preserves state across tab switches, with onActivated / onDeactivated hooks.
  • <Teleport> moves rendered DOM (modals, toasts) to another target while keeping the component's state, props and events intact.