Components & SFCs
Single-file components, the component tree and Vite tooling.
A Vue application is a tree of components. Each component is a self-contained piece of UI with its own state, template and styles: a button, a task card, a whole page. You build small components, compose them into bigger ones, and mount the root of that tree onto a single DOM element.
This lesson covers what a component is, the Single-File Component (.vue) format, how Vite turns .vue files into JavaScript the browser can run, and how all of this plugs into a Laravel Blade page.
Thinking in components
Take the Task board we'll build up across this track. Before writing any code, break the UI into boxes:
Some rules of thumb for drawing the boundaries:
- One responsibility per component.
TaskItemrenders one task. It doesn't know how tasks are loaded. - Repeated UI is a component. Anything you'd copy-paste in a template should be extracted.
- Each instance has its own state. Five
TaskItems share one definition but have five independent copies of local state. - Data flows down, events flow up. Parents pass data to children as props; children notify parents with events. That's the Props & Emits lesson.
Anatomy of a Single-File Component
In a real project each component lives in its own .vue file with three optional blocks:
<!-- resources/js/components/TaskItem.vue -->
<script setup>
import { ref } from 'vue';
const props = defineProps({
title: { type: String, required: true },
priority: { type: String, default: 'normal' },
});
const done = ref(false);
</script>
<template>
<li class="task" :class="{ 'task--done': done }">
<input type="checkbox" v-model="done">
<span>{{ title }}</span>
<small class="badge">{{ priority }}</small>
</li>
</template>
<style scoped>
.task { display: flex; gap: .5rem; align-items: center; }
.task--done span { text-decoration: line-through; opacity: .6; }
.badge { margin-left: auto; font-size: .75rem; }
</style>
| Block | Purpose | Notes |
|---|---|---|
<script setup> |
Component logic: state, props, functions | Top-level bindings are automatically available in the template |
<template> |
The markup, using Vue's template syntax | Compiled to a render function at build time |
<style scoped> |
CSS for this component | scoped rewrites selectors so they only match this component's elements |
Here is the same component running live. The demo uses Vue's in-browser build, so the component is defined as a JavaScript object instead of a .vue file. The behaviour is identical:
How scoped styles work
Vue adds a unique attribute to every element the component renders and rewrites your selectors to include it:
<!-- Illustrative: what the SFC compiler outputs -->
<li class="task" data-v-7ba5bd90>…</li>
/* Illustrative: compiled CSS */
.task[data-v-7ba5bd90] { display: flex; gap: .5rem; align-items: center; }
Styles can't leak out of the component, and generic class names like .badge can't collide with another component's .badge. To style a child component's internals from a parent, use the :deep() pseudo-class: .wrapper :deep(.badge) { … }.
Why single-file components. Separation of concerns isn't separation of file types. A component's markup, logic and styles change together, so co-locating them makes components easier to move, delete and reason about. The build step also lets Vue pre-compile templates and do static analysis that a runtime-only approach can't.
From .vue file to the browser: Vite
Browsers don't understand .vue files. A build tool, Vite with @vitejs/plugin-vue, transforms each SFC into a plain ES module:
- In development (
npm run dev), Vite serves each file as a native ES module on demand and uses Hot Module Replacement. Edit a.vuefile and only that component is swapped in the browser, with its state kept. - In production (
npm run build), Vite uses Rollup to bundle, tree-shake and minify everything into hashed files inpublic/build/.
A minimal Vite config for a Laravel + Vue project:
// vite.config.js (illustrative: build config, not run on this page)
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({ input: ['resources/js/app.js'], refresh: true }),
vue(),
],
});
Runtime vs build-time compilation: with a build step, templates are compiled ahead of time and you ship the smaller runtime-only build of Vue. The live demos on this page use the full build (vue.global.js), which includes the template compiler so it can compile template strings in the browser. That's handy for demos and progressive enhancement, but about 40% larger.
Registering and using components
Local registration (the default)
With <script setup>, importing a component is enough. No registration step is needed:
<!-- TaskList.vue -->
<script setup>
import { ref } from 'vue';
import TaskItem from './TaskItem.vue';
const tasks = ref([
{ id: 1, title: 'Write migration', priority: 'high' },
{ id: 2, title: 'Build TaskItem component', priority: 'normal' },
{ id: 3, title: 'Mount the board with @vite', priority: 'low' },
]);
</script>
<template>
<ul>
<TaskItem
v-for="task in tasks"
:key="task.id"
:title="task.title"
:priority="task.priority"
/>
</ul>
</template>
This is the parent in the TaskItem demo above: the same three tasks rendered through the same TaskItem component. Local registration is tree-shakeable. If no component imports TaskItem, it isn't bundled.
Global registration
For components used everywhere, such as a BaseButton or an icon, you can register them once on the app:
// Illustrative: app entry point in a Vite project
import { createApp } from 'vue';
import App from './App.vue';
import BaseButton from './components/BaseButton.vue';
const app = createApp(App);
app.component('BaseButton', BaseButton); // usable in every template
app.mount('#app');
Use global registration sparingly. It hides dependencies and every global component ends up in the main bundle.
Naming
- File and component names in PascalCase:
TaskItem.vue. - Use multi-word names (
TaskItem, notItem) to avoid clashing with current or future HTML elements. - In SFC templates you can write
<TaskItem />. In in-DOM templates (HTML written directly in a Blade file) you must use kebab-case,<task-item></task-item>, because HTML is case-insensitive.
Mounting Vue in a Laravel app
A Vue app always starts the same way: create an app from a root component, then mount it onto an element.
// resources/js/app.js (illustrative: a typical Laravel + Vite entry point)
import { createApp } from 'vue';
import TaskBoard from './components/TaskBoard.vue';
const el = document.getElementById('task-board');
if (el) {
createApp(TaskBoard, {
// Pass server data in via data-* attributes
initialTasks: JSON.parse(el.dataset.tasks ?? '[]'),
}).mount(el);
}
<!-- resources/views/tasks/index.blade.php (illustrative) -->
@extends('layouts.app')
@section('content')
<div id="task-board" data-tasks='@json($tasks)'></div>
@endsection
@push('scripts')
@vite('resources/js/app.js')
@endpush
This is the "islands" approach. Laravel renders the page, and Vue takes over one or more regions. The live demos on this page use the same idea for real: each <div class="demo"> is its own small Vue app, mounted by a little loader script instead of a Vite bundle. The alternative, where Vue owns the whole page and Laravel acts as an API or uses Inertia, is covered in Vue + Laravel in Production.
Escaping JSON into attributes: @json escapes HTML-sensitive characters. Wrap the attribute in single quotes so the double quotes in the JSON don't end it early. Never echo user data into a raw <script> tag without @json or Js::from().
Multiple instances, independent state
A component definition is a blueprint. Each time you use the tag you get a fresh instance with its own reactive state. Click the counters below. They're three instances of the same component:
<!-- ClickCounter.vue -->
<script setup>
import { ref } from 'vue';
defineProps({ label: String });
const count = ref(0); // created per instance, inside setup
</script>
<template>
<button @click="count++">{{ label }}: {{ count }}</button>
</template>
Why data is a function in the Options API. The function is called once per instance and returns a fresh object. If data were a plain object, every instance would share it, and clicking one counter would change them all. <script setup> avoids the issue entirely because setup() already runs once per instance.
Key takeaways
- A Vue UI is a tree of components. Each has its own state, template and (optionally) scoped styles.
- SFCs co-locate
<script setup>,<template>and<style scoped>in one.vuefile. - Vite and
@vitejs/plugin-vuecompile SFCs into ES modules: fast HMR in development, bundled and hashed assets in production. - Prefer local registration (just import it). Reserve global registration for truly universal base components.
- In Laravel, load your entry point with
@viteand mount Vue onto an element, passing server data through props ordata-*attributes.