Skip to content
beginner Phase 5 · Components, Props, and Events

Registering Components

Use components locally in script setup or register them globally.

40m
0 problems
Topic Progress 0%

Import and Use: How script setup Registers a Component for Free

A cart page renders a LineItem card per row and an IconButton for removal. With <script setup>, registration is one statement that looks like nothing special:

<script setup>
import LineItem from './LineItem.vue'
import IconButton from './RemoveIcon.vue'
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <LineItem :item="item" />
      <IconButton label="Remove" @click="remove(item.id)" />
    </li>
  </ul>
</template>

Top-level imports in <script setup> are exposed to the template as bindings, and the template compiler treats any capitalized tag or known kebab-case tag as a lookup against those bindings. <LineItem /> resolves to the imported SFC, gets compiled into the right vnode call, and the import graph stays truthful: the file tells you exactly which child components exist, your editor jumps to them, and the bundler sees the dependency for chunking and tree-shaking. There is no registration API to call and no registry to keep in sync — the import is the registration.

The failure mode is instructive. Delete the import and keep the template tag: nothing crashes at build time in a plain setup, and the dev console shows Failed to resolve component: LineItem while the page renders a literal <lineitem> element in the DOM. Silent-ish degradation is deliberate — unknown tags might be custom elements — but it means "my component rendered as empty space" almost always traces back to a missing or misspelled import.

The Options API equivalent, for codebases still mixing styles, is the components option:

export default {
  components: { LineItem, IconButton },
  setup() { /* ... */ }
}

Same effect — the names become resolvable in this component's template — but with two registration steps (import, then list) that can drift apart, which is part of why <script setup> made the import sufficient.

A subtlety that matters for large apps: registration is per-component, not per-app. LineItem is resolvable inside the file that imported it and nowhere else. Each consumer imports what it uses, so a refactor that deletes a page's last use of LineItem leaves the import unused and the bundler drops the component from the chunk — the accuracy of bundle analysis is a direct payoff of local registration, and the reason global registration is the pattern to justify, not the default.

Back on the import itself: it composes with async loading under the same rule. const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue')) at the top of <script setup> registers the async form as an ordinary template binding, and the outlet renders the configured fallback until the chunk lands — registration and lazy loading are two settings of the same mechanism, not two systems. Two failure modes round out the picture. First, the unresolved-tag warning is development-only: a misspelled <UserCard> logs Failed to resolve component: UserCard in dev and silently renders an unknown element in production, which is one reason end-to-end suites run against dev builds or a lint rule bans unknown tags outright. Second, auto-import plugins (unplugin-vue-components and friends) can erase the ceremony by scanning your components directory and injecting imports for any tag they recognize. That works, and in a component-library repo with hundreds of primitives it earns its keep; the cost is that the file stops stating its dependencies, resolution becomes build-plugin behavior instead of JavaScript, and a renamed directory breaks templates in ways grep cannot follow. The interview framing is the same argument Choosing Computed vs watch made about state: prefer the mechanism that declares the dependency over the one that infers it.

Recursive components work under the same rule: because the filename gives the component a name it can reference, a TreeNode.vue file may use <TreeNode> in its own template without importing itself. If you need a component under a different name than its filename, or need a stable name for caching and devtools, that is an explicit-naming job — the next chapter's subject.

One Component, Three Spellings: PascalCase, kebab-case, and Where Each Breaks

A component file UserCard.vue can appear in templates as <UserCard> or <user-card>. The mapping is not cosmetic — it differs per template dialect, and the wrong spelling fails in ways that look like registration bugs.

In SFC templates, both spellings work and are equivalent. The compiler normalizes tag names during resolution, so <UserCard> and <user-card> hit the same imported binding. The community convention, enforced by most style guides, is to use PascalCase in SFC templates and JSX, kebab-case in in-DOM templates. Consistency matters more than which side you pick, because mixed spellings of the same component in one file make reviews harder for zero benefit.

In-DOM templates — inline template: strings, index.html, or server-rendered markup — only kebab-case works. The browser's HTML parser is case-insensitive: <userCard /> arrives as a lowercase usercard tag, and Vue's resolution finds nothing. This bites two specific audiences: people configuring components via template option strings, and teams using in-browser templates from older Vue patterns.

JSX requires the identifier. In JSX, <UserCard /> is a JavaScript expression — the imported variable — so PascalCase is the only option that resolves, and kebab-case strings are looked up in the registry instead.

Under the hood, resolution normalizes both directions: registered names are stored camel/Pascal-cased, and kebab-case tags are matched against them by case conversion. Local imports are checked before the global registry, so a local UserCard shadows a globally registered UserCard — a fact worth knowing when a global upgrade mysteriously doesn't appear in one file.

The multi-word rule. The Vue ESLint plugin's multi-word-component-names rule rejects files like Table.vue or Footer.vue, and the reasoning is concrete: a single-word name collides with an HTML element (Table.vue shadows how you'd ever render a real <table> inside itself and confuses resolution), and generic names collide with future components as the app grows — Card.vue vs HeaderCard vs UserCard is a naming mess only discovered at scale. The exemption is App.vue, the root, which no tag ever references.

When the filename is the wrong name. The component's name defaults to its filename, but three mechanisms need a specific name and can't tolerate a mismatch:

  • keep-alive's include/exclude props match by component name — a cached component whose filename doesn't match the prop values is silently not cached (see Caching with keep-alive).
  • Devtools displays components by name; anonymous or machine-generated names make a large tree unreadable.
  • Dynamic <component :is="'user-card'"> with a string resolves against registered names.

defineOptions sets it without a second <script> block:

<script setup>
import { ref } from 'vue'
defineOptions({ name: 'AccountSummary' })   // for keep-alive include, devtools, string :is
const open = ref(false)
</script>

Two edge cases in the mapping deserve memory. First, kebab conversion of consecutive capitals is toolchain-dependent: MyURLCard may come out as my-urlcard or my-url-card depending on who does the conversion, so the safe convention is PascalCase without acronym runs (UrlCard) — every converter agrees on it and every reader can spell it in a template. Second, filename inference exists only for SFCs: a component created inline with defineComponent({ ... }) and no name option has no name at all, so every name-matching system silently skips it. That is the concrete reason the style guide treats the filename as identity — caching, devtools grouping, string :is lookup, and lint rules all resolve against a name that, for a script-setup component, the filename alone provides until defineOptions says otherwise. Treat a rename as an API change and review it like one.

The one-line discipline: treat the filename as the public name of the component. Import under that name, use PascalCase in SFCs, kebab-case in the DOM, and reach for defineOptions only when a consumer keys off the name itself.

app.component: Paying the Global Tax, and the Cases That Still Justify It

Global registration puts a component in one app-wide registry so every template in the app can resolve it with no import:

import { createApp } from 'vue'
import App from './App.vue'
import Badge from './Badge.vue'

const app = createApp(App)
app.component('Badge', Badge)      // before mount — every component may use <Badge>
app.mount('#app')

It solves a real annoyance in specific setups — a design-system primitive used in hundreds of files, a third-party library whose docs say "register then use", an admin tool where import bookkeeping slows more than it helps. But every global registration extracts payment in three currencies, and an interviewer asking "local or global, why?" is fishing for exactly these.

1. Tree-shaking accuracy degrades. The registration file imports Badge, so the bundler must keep it — in every build that includes the registration file, which is all of them, because the registration is app-wide. Remove the last page that used Badge and the component still ships. Local imports let dead components fall out of the bundle naturally; globals pin them in. On a large app this is measurable weight, and it compounds: a components/index.js that registers thirty primitives creates a chunk where thirty components rise and fall together.

2. Collisions resolve silently. Two app.component('Card', ...) calls — two feature teams, one shared name — and the last registration wins with no warning. With local imports the same mistake is a duplicate import you can see, and two different files importing ./Card.vue and ./CardAlt.vue under different identifiers never meet.

3. Discoverability dies. Reading a template that uses <Badge>, there is no import to click, no way to answer "where does this come from and what props does it take" without searching the whole app for the registration. Local registration keeps the component graph readable as plain file structure.

Resolution order is worth stating precisely because it enables a hybrid: self → local components/imports → global registry. A local registration shadows a global one of the same name, so an app can migrate a global component to local, file by file, without breaking other consumers.

When global is genuinely right: plugins shipping a component set (app.use(SomeLibrary) registering its own parts is the normal plugin pattern — you don't hand-edit its registrations); app-wide primitives you have decided always ship (a ThemeToggle, an ErrorBoundary) where the tree-shaking cost is accepted deliberately; and string-driven rendering where components are looked up by name at runtime across module boundaries that make imports awkward. The discipline that makes it safe: register centrally in one file, use multi-word names prefixed by the library or app name (AcmeBadge) to make collisions unlikely by construction, and prefer <script setup> imports everywhere else.

Two facts about the registry complete the global picture. It lives on the app instance, not the module: two createApp calls on one page — an embedded widget plus the host app — carry separate registries, so each app must register its own Badge and neither can leak into the other. That isolation is useful until you assumed app.component was process-global and the widget renders unknown tags in production. And the plugin idiom is the case where globals are genuinely right: a library's install(app) function calls app.component for each piece it ships, because the author cannot write imports into your files — a registry is the only channel available to them. The senior-level answer to "local or global, why?" therefore carries the prices attached: local by default — tree-shaking, collision visibility, a readable import graph — and global for plugin-shipped sets and deliberately always-shipping primitives, centralized in one file and prefixed (AcmeBadge) so collisions fail by construction rather than silently.

Registration is only the delivery mechanism, though — once a component is resolvable, the real contract between parent and child begins with Declaring and Validating Props.

Quiz

1. In a <script setup> SFC, why does <UserCard /> in the template render the imported component?

Question 1 options

2. You use <userCard /> inside an in-DOM template (an inline template: string). It fails to resolve. Why?

Question 2 options

3. Why does the Vue ESLint config reject a component file named Table.vue?

Question 3 options

4. What are the two concrete costs of registering components with app.component()?

Question 4 options

Flashcards

Question

How does <script setup> register an imported component, and what happens without the import?

Answer

Top-level imports are exposed as template bindings, and the compiler resolves capitalized or known kebab-case tags against them — the import IS the registration, per-component and tree-shakeable. Without the import the dev console warns 'Failed to resolve component: X' and the tag renders as a literal unknown element.

Question

Where are PascalCase and kebab-case component tags valid, and where does each break?

Answer

SFC templates: both (compiler normalizes; PascalCase is convention). JSX: PascalCase only (it's an identifier expression). In-DOM templates (inline template strings, index.html): kebab-case only — the HTML parser lowercases tags, so <userCard> arrives as 'usercard' and fails to resolve.

Question

Why the multi-word component name rule, and when do you override the filename-derived name?

Answer

Single-word names collide with HTML elements (Table.vue shadows <table>) and with future components; App.vue is exempt. Override via defineOptions({ name }) when a consumer keys off the name: keep-alive include/exclude matching, devtools readability, and string-based <component :is="'name'">.

Question

What is Vue's component resolution order?

Answer

Self-reference (by filename) → local registration (script setup imports or the components option) → the global app registry. Local shadows global, which lets you migrate a globally registered component to local imports file-by-file without breaking other consumers.

Question

When is app.component global registration the right choice, and what discipline does it require?

Answer

Right for: plugin-shipped component sets (app.use(SomeLibrary)), deliberately-always-shipping app primitives, and heavy string-driven runtime lookup. Costs: no tree-shaking of unused components, silent last-wins name collisions, no import for discoverability. Discipline: one central registration file, multi-word app/library-prefixed names (AcmeBadge), local imports everywhere else.

Revision Notes

Key Takeaways

  • 1. In script setup, a top-level import is the registration — per-component, discoverable, and tree-shakeable; no registry step exists to forget
  • 2. Tag spelling follows the dialect: PascalCase in SFC/JSX, kebab-case mandatory wherever a real HTML parser is involved
  • 3. Component names default to filenames and must be multi-word; defineOptions sets explicit names for keep-alive matching, devtools, and string resolution
  • 4. Resolution checks self, then local, then global — local shadowing enables incremental migration off globals
  • 5. Global registration trades tree-shaking accuracy, collision safety, and discoverability for import-free convenience; reserve it for plugins and deliberate always-shipping primitives

Interview Tips

  • • "How do components get registered in script setup?" — imports become template bindings and the compiler resolves tags against them; contrast with the Options API's two-step import + components option
  • • "Local or global registration?" — local by default: tree-shaking, collision visibility, greppable provenance; name the globals' three costs and the plugin scenario where globals are still right
  • • "Why won't my component resolve?" — walk the list: missing/misspelled import, kebab/Pascal mismatch in an in-DOM template, name not in the registry for string :is, or a local shadow you didn't expect
  • • "What's keep-alive got to do with component names?" — include/exclude match by component name, so caching silently skips components whose (filename-derived) name doesn't match; defineOptions fixes it

Cheat Sheet

Component Registration

Default path (script setup):

<script setup>
import UserCard from './UserCard.vue'   // import = registration, per-file
import { ref } from 'vue'
defineOptions({ name: 'AccountCard' }) // explicit name: keep-alive/devtools/string :is
</script>
<template>
  <UserCard />      <!-- SFC/JSX: PascalCase (convention) -->
  <user-card />     <!-- same component; required spelling in in-DOM templates -->
</template>

Spellings: SFC = both · JSX = PascalCase · in-DOM = kebab-case only (HTML lowercases tags).

Names: multi-word (eslint rule; App.vue exempt) — avoids HTML-element and future collisions; filename is the public name unless defineOptions overrides.

Resolution order: self (filename) → local (imports / components option) → global (app.component). Local shadows global.

Global — the tax: no tree-shaking (registration file pins components into every build) · silent last-wins collisions · no import to make provenance discoverable. Accept it deliberately: plugin sets, always-shipping primitives, string-driven rendering — centralize registrations and prefix names.

Forgot the import? dev warning 'Failed to resolve component: X' + literal unknown element in the DOM — the #1 cause of 'my component renders as nothing'.

Continue Learning