Skip to main content

Vue 02 - Components & Routing

Components

Components are reusable building blocks. Each component is a .vue SFC file with its own template, logic, and styles.

Creating and using a component

ButtonCounter.vue

<script setup lang="ts">
import { ref } from "vue";

const count = ref(0);

const increment = () => {
count.value++;
};
</script>

<template>
<button @click="increment">Count is: {{ count }}</button>
</template>

App.vue — import and use the component as a custom HTML tag:

<script setup lang="ts">
import ButtonCounter from "./components/ButtonCounter.vue";
</script>

<template>
<h1>My App</h1>
<ButtonCounter />
<ButtonCounter />
</template>

Each <ButtonCounter /> instance maintains its own independent state.

Props

Props pass data from parent to child. Use defineProps with a TypeScript interface for type safety.

<script setup lang="ts">
interface Props {
foo: string;
bar?: number;
}

const props = defineProps<Props>();
</script>

Default values

Use withDefaults to provide default values for optional props:

export interface Props {
msg?: string;
labels?: string[];
}

const props = withDefaults(defineProps<Props>(), {
msg: "hello",
labels: () => ["one", "two"],
});

Using props in parent

<script setup lang="ts">
import ButtonCounter from "./components/ButtonCounter.vue";
</script>

<template>
<!-- Static prop -->
<ButtonCounter msg="Hello" />

<!-- Dynamic prop (bound to a variable or expression) -->
<ButtonCounter :msg="greeting" :labels="['a', 'b']" />
</template>

Custom events (emits)

Child components communicate back to parents through events. Use defineEmits for type-safe event declarations.

<script setup lang="ts">
const emit = defineEmits<{
(e: "change", id: number): void;
(e: "update", value: string): void;
}>();

const handleClick = () => {
emit("change", 42);
};
</script>

<template>
<button @click="handleClick">Change</button>
<!-- Or emit directly in template -->
<button @click="$emit('update', 'new value')">Update</button>
</template>

Parent listens to the event:

<MyComponent @change="(id) => console.log(id)" @update="handleUpdate" />

Unlike native DOM events, component emitted events do not bubble.

Component v-model

v-model on a component creates two-way binding. Since Vue 3.4, use the defineModel macro for the simplest approach:

<script setup lang="ts">
const title = defineModel<string>("title");
</script>

<template>
<input type="text" v-model="title" />
</template>

Parent usage:

<MyComponent v-model:title="bookTitle" />

The older pattern (before 3.4) uses defineProps + defineEmits with update:propName:

<script setup lang="ts">
defineProps<{ title: string }>();
defineEmits<{ (e: "update:title", value: string): void }>();
</script>

<template>
<input
type="text"
:value="title"
@input="$emit('update:title', ($event.target as HTMLInputElement).value)"
/>
</template>

Slots

Slots let a parent inject content into a child component's template. The child defines where the content appears with <slot>.

<!-- Parent -->
<FancyButton>
Click me!
</FancyButton>

<!-- FancyButton.vue -->
<template>
<button class="fancy-btn">
<slot>
Submit
<!-- fallback content if nothing provided -->
</slot>
</button>
</template>

Named slots

For multiple content areas, use named slots:

<!-- Parent -->
<PageLayout>
<template #header>
<h1>Page Title</h1>
</template>
<template #default>
<p>Main content here</p>
</template>
<template #footer>
<p>Footer content</p>
</template>
</PageLayout>

<!-- PageLayout.vue -->
<template>
<header><slot name="header"></slot></header>
<main><slot></slot></main>
<footer><slot name="footer"></slot></footer>
</template>

Slot content has access to the parent's scope, not the child's.

Template refs

For direct access to DOM elements, use template refs.

<script setup lang="ts">
import { ref, onMounted } from "vue";

const inputRef = ref<HTMLInputElement | null>(null);

onMounted(() => {
inputRef.value?.focus();
});
</script>

<template>
<input ref="inputRef" />
</template>

Vue Lifecycle

Vue lifecycle

Key lifecycle hooks in Composition API:

import {
onMounted,
onUpdated,
onUnmounted,
onBeforeMount,
onBeforeUpdate,
onBeforeUnmount,
onErrorCaptured,
onRenderTracked,
onRenderTriggered,
onActivated,
onDeactivated,
onServerPrefetch,
} from "vue";

onMounted(() => {
// Component is mounted to DOM — access DOM elements, start timers, fetch data
});

onUnmounted(() => {
// Cleanup — remove event listeners, clear timers
});

Most commonly used: onMounted (initial data fetching, DOM access) and onUnmounted (cleanup).


Vue Router

Vue Router provides client-side navigation between views without full page reloads.

Install (or select during project creation):

npm install vue-router@4

Setup

Register the router in main.ts:

import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";

const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

This enables:

  • <RouterView> and <RouterLink> components
  • useRouter() and useRoute() composables

Route configuration

src/router/index.ts

import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import HomeView from "../views/HomeView.vue";

const routes: RouteRecordRaw[] = [
{
path: "/",
name: "Home",
component: HomeView,
},
{
path: "/owners",
name: "OwnersIndex",
// Lazy loading — component is loaded only when route is visited
component: () => import("../views/owners/IndexView.vue"),
},
{
// Dynamic segment :id becomes a route param
path: "/owners/edit/:id",
name: "OwnersEdit",
component: () => import("../views/owners/EditView.vue"),
props: true, // Maps route params to component props
},
{
// Catchall — matches any unmatched path
path: "/:pathMatch(.*)*",
name: "NotFound",
component: () => import("../views/NotFoundView.vue"),
},
];

const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});

export default router;

RouterView

The matched component renders where <RouterView /> is placed:

<template>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/owners">Owners</RouterLink>
</nav>

<!-- Matched route component renders here -->
<RouterView />
</template>

Receiving route params as props

When props: true is set on the route, params are mapped to component props:

<script setup lang="ts">
const props = defineProps<{
id: string;
}>();

// props.id contains the value from the URL
</script>

Declarative — using <RouterLink>:

<RouterLink to="/owners">Owners</RouterLink>
<RouterLink :to="{ name: 'OwnersEdit', params: { id: owner.id } }">
Edit
</RouterLink>

Programmatic — using useRouter():

<script setup lang="ts">
import { useRouter, useRoute } from "vue-router";

const router = useRouter();
const route = useRoute();

const goToEdit = (id: string) => {
router.push({ name: "OwnersEdit", params: { id } });
};

// Access current route info
console.log(route.params.id);
console.log(route.query.search);
</script>

Guards protect routes — for example, redirecting unauthenticated users to login.

Global guard in src/router/index.ts:

import { useAuthStore } from "@/stores/auth";

router.beforeEach((to, from) => {
const authStore = useAuthStore();

// List of routes that require authentication
const protectedRoutes = ["OwnersIndex", "OwnersEdit"];

if (protectedRoutes.includes(to.name as string) && !authStore.isAuthenticated) {
return { name: "Login" };
}
});

Watching route changes

When the same component is reused for different route params (e.g., /owners/edit/1 then /owners/edit/2), use a watcher to react to param changes:

<script setup lang="ts">
import { useRoute } from "vue-router";
import { ref, watch } from "vue";

const route = useRoute();
const userData = ref();

watch(
() => route.params.id,
async (newId) => {
userData.value = await fetchUser(newId as string);
}
);
</script>