Vue – Components

November 2, 20253 min readUpdated 8/24/2026

A component is a .vue file. Using one is importing it:

<script setup>
import ReelCard from "../../components/public/ReelCard.vue";
import EmptyState from "../../components/ui/EmptyState.vue";
</script>

<template>
  <EmptyState v-if="!reels.length" title="Nothing here yet" />
  <ReelCard v-for="reel in reels" :key="reel.id" :reel="reel" />
</template>

There is no registration step. With <script setup>, an imported binding is available to the template, and a component is just a binding. That is the whole API.

Without <script setup> you would need a components: { ReelCard } option, which is the extra line the modern syntax removes.

Naming and casing

Name component files in PascalCase and use them in templates the same way: ReelCard.vue becomes <ReelCard>.

Vue also accepts kebab-case in templates — <reel-card> — which exists for templates written directly in an HTML file, where the browser lowercases everything before Vue sees it. With a build step you never need it, and PascalCase has a real advantage: it is visibly distinct from a real HTML element.

Two naming rules worth keeping:

Use multi-word names. <Card> risks colliding with a current or future HTML element; <ReelCard> cannot.

Prefix by what it is, not what page it is on. ReelCard, ReelPlayer, ReelListView sort together and read as a family.

A component with no logic is still worth making

<script setup>
defineProps({
  label: { type: String, default: "Loading…" },
  compact: { type: Boolean, default: false },
});
</script>

<template>
  <div class="text-center text-secondary" :class="compact ? 'py-3' : 'py-5'">
    <div class="spinner-border spinner-border-sm text-primary" role="status"></div>
    <div class="small mt-2">{{ label }}</div>
  </div>
</template>

Twelve lines, two props, no state. It earns its place because the alternative is that markup copy-pasted into fifteen views, and the day someone wants a different spinner they have to find all fifteen.

The test for whether something should be a component is not complexity. It is whether it is a thing with a name.

How the demo application organises them

src/
  components/
    ui/       LoadingSpinner, EmptyState, PaginationBar, StatusBadge, AppToast
    public/   ReelCard, ReelPlayer, CommentPanel
    admin/    MediaUpload, StatCard, TagInput
  layouts/    PublicLayout, AdminLayout
  views/
    public/   FeedView, ExploreView, ReelView, CreatorView, Collections…
    admin/    DashboardView, ReelListView, ReelEditView, LoginView…

The split is by who is allowed to use it, which is more useful than splitting by type.

ui/ knows nothing about reels. It takes props, emits events, and could be lifted into another project unchanged. Nothing in it imports a store or the API.

public/ and admin/ know about the domain. A component here can assume there is such a thing as a reel.

views/ are routed. One per route, and they are the components allowed to fetch data and own page state.

layouts/ wrap a group of routes with shared chrome — the navbar for the public site, the sidebar for the admin. Lesson 19 shows how the router mounts them.

The rule that keeps this honest: data flows down from views. A view fetches; the components below it receive props. When a leaf component starts importing the API you have lost the ability to reason about when requests happen.

Every component is isolated

State declared in a component belongs to that instance. Render <ReelCard> twenty times and there are twenty independent copies of anything it declares.

That is obvious for refs inside the component and less obvious for a ref declared at module scope, outside the component:

// Module scope -- ONE of these exists, shared by every instance. Occasionally
// what you want, and a genuinely confusing bug when it is not.
const shared = ref(0);

export default { /* ... */ };

If you want shared state, use a store and be explicit about it — lesson 17.

Global registration

A component used on nearly every page can be registered once on the app instead:

app.component("LoadingSpinner", LoadingSpinner);

It is then available everywhere with no import. The demo application does not do this for any component, deliberately: a global has no import to follow, so a reader cannot tell where <LoadingSpinner> comes from, and a bundler cannot tree-shake it. The saving is one import line. It is rarely worth it.

Next: Props — getting data into a component.