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.