An Introduction to Vue 3 and Typescript: Functional components, attributes, and slots

After getting a grip on reactivity and component communication, let's look at what basic features remain. Take slots and attributes. We use them mostly in templates. But what if you must access them in your script? Say, to write a functional component?

Functional components

The official docs recommend using HTML templates whenever possible. And in most cases, you don't have to access attributes and slots programmatically.

When using functional components, you're essentially skipping Vue's template compiling process. Instead of HTML, you pass virtual node declarations into the framework's renderer pipeline. And without templates, there's no place to nest attributes and slots.

You'll notice that in the end we'll still use templates. Bear with me.

This can be super useful in cases where you need low-level control of how your app behaves. But it comes at the cost of an extended boilerplate.

Attributes and slots

Attributes in Vue 3

If you've carefully read my previous article about v-model - binding, you probably noticed a v-bind directive that went along unexplained:  

<input
    class="input__field"
    v-bind="$attrs"
    :placeholder="label ? label : ''"
/>

$attrs includes a map of all HTML - fallthrough-attributes that were passed into the component. It is automatically provided by Vue when the component is mounted. By using v-bind, we can declaratively bind the map to the input element. Else, Vue will bind them to the outer div. Especially for accessibility attributes, we would prefer the former.

Attributes are only available in the template. If we wanted programmatic access to them we must make use of the useAttrs helper.

import { useAttrs } from 'vue';
const attrs = useAttrs();

Slots in Vue 3

At some point, props will not satisfy your app's need for dynamic content. That's when slots come into play. They allow you to inject whole templates into child components.

Assume you had a AppCard.vue component. It acts as an outer, styled layer for its content and looks something like this:

<template>
    <section class="card">
        <header class="card__header">
            Some card header
		</header>

		<main class="card__body">
            This is some card content
		</main>

		<footer class="card__footer">
            This is a card footer
		</footer>
	</section>
</template>

We can replace the static content with named slots:
(back to 'A functional container component')

<template>
	<section class="card">
		<header class="card__header">
            <h3>
                <slot name="header" />
            </h3>
		</header>

		<main class="card__body">
			<slot name="default" />
		</main>

		<footer class="card__footer">
			<slot name="footer" />
		</footer>
	</section>
</template>

And, in the outer component, pass in slots with the v-slot:<slotname> - syntax:

<template>
    <app-card>
        <template v-slot:header> Card header</template>
		<template v-slot:default>
            <p>
                Lorem ipsum, dolor sit amet consectetur adipisicing elit. 
                Enim ipsa ullam culpa explicabo amet alias nemo!
			</p>
		</template>
		<template v-slot:footer> Card footer </template>
	</app-card>
</template>

To access slots programmatically, we must use the useSlots helper exported by Vue:

import { useAttrs } from 'vue';
const attrs = useAttrs();

A functional container component

Let's stick with the card sample for a moment. What if you wanted to have a different tag wrapping the card? Or a smaller heading for the headline? Say h4 instead of h3?

Problems like this can hardly be solved in a static template. Functional components shine here - they provide the necessary flexibility and require (almost) no template.

Let's showcase this. We'll want to build a more flexible container component. It should always:

  • have margins to the left and right
  • fill 100% of the horizontal viewport, but not more than 1200px

Additionally, it should come in two combinable variants:

  • centered - all elements inside the container are horizontally and vertically centered
  • page - the container fills 100% of the vertical viewport

The result will look like this:

Start with this Github repos. Clone it to your local machine and:

  • create the AppContainer.vue component in src/components/
  • (optional): create the AppCard.vue component in src/components/ and fill it with the dynamic component of 'Slots in Vue 3'
  • grab the below boilerplate code and add it to the respective file

AppContainer.vue

<script setup lang="ts">
import { h, Component, useSlots, useAttrs } from 'vue';
interface AppContainerProps {
	tag: keyof HTMLElementTagNameMap;
	page?: boolean;
	centered?: boolean;
}
type AppContainerClass = 'container--centered' | 'container--page';

const props = withDefaults(defineProps<AppContainerProps>(), {
	tag: 'div',
	centered: false,
	page: false,
});

const propClassMap: {
	prop: keyof AppContainerProps;
	class: AppContainerClass;
}[] = [
	{
		prop: 'centered',
		class: 'container--centered',
	},
	{
		prop: 'page',
		class: 'container--page',
	},
];

const assembleContainerClasses = () => {
	let containerClasses = ['container'];
	propClassMap.forEach((entry) => {
		if ([props[entry.prop]]) {
			containerClasses.push(entry.class);
		}
	});
	return containerClasses;
};
</script>

<style scoped>
.container {
	background-color: var(--background-color-tartiary);
	margin: auto;
	width: 100vw;
	max-width: 1200px;
	padding-left: 5rem;
	padding-right: 5rem;
}

.container--centered {
	display: flex;
	flex-grow: 0;
	flex-direction: column;
	align-items: center;
	justify-content: center;
}

.container--page {
	min-height: 100vh;
}
</style>

A lot is going on here. Let's walk through it from the top.

  1. We import the necessary helpers from 'vue'
  2. We declare an interface and a type. These will provide shape to our functional component's props and utilities
  3. We declare the component's props
  4. We create a propClassMap. It builds a reference between the component's properties and the CSS classes they are meant to apply
  5. We declare a utility function assembleContainerStyles. It'll be called once when the component is created and apply the required CSS classes
  6. Finally, we declare the CSS styles themselves below the script tag

Declare attributes and slots

Add these lines of code right below the component's props:

const slots = useSlots();
const attrs = useAttrs();

Declare the component

Add the following lines of code right below the assembleContainerStyles - function:

Inside the script tag

const AppContainer: Component = () => {
	return h(props.tag, attrs, slots);
};

Below the script tag

<template>
    <app-container :class="assembleContainerStyles()">
		<slot />
	</app-container>
</template>

Add the component to the parent component

What's left to do is to add the following code to your App.vue file:

<template>
	<app-container tag="main" :page="true" :centered="true">
		<app-card>
			<template v-slot:header> Functional components</template>
			<template v-slot:default>
				<p>
					This card element is a default HTML template while the outer container is rendered
					dynamically. You're free to toggle its tag, whether it should fill the whole page or
					whether this card is centered.
				</p>
			</template>
			<template v-slot:footer> Card footer </template>
		</app-card>
	</app-container>
</template>

Again, if you have the Vue Language Features extension enabled in VSCode, you'll be happy to see intelligent code autocompletion also works for functional components:

Hold on. But why the template in AppContainer?

You might wonder: Why is there still a template? Weren't we creating a functional component?

That's perfectly true. You could achieve the same result using functional syntax. I personally prefer having Single File Components as my project's foundation. But with Vue 3, achieving the same result using a functional approach in a .ts - file is easier than ever before.

Note that the official docs state that the performance gained using function syntax became neglectable in Vue 3. Read more about it here.

If this answer does not satisfy you, I'll be happy to see how you build this component with pure functional syntax. Please let me know about your learnings and if there are edge cases in which SFC syntax really cannot be used.