In a Vue 3 `<script setup>` tab switcher, why does binding `<component :is>` to a name string or a ref-held component misbehave?
answer
- what the lookup can see
- script setup imports are not registered
- unmatched strings become tags
- deep proxies around definitions
- keep definitions raw
basics
~20 sA string for Vue's :is resolves only registered component names, and script setup imports are not registered, so it renders an unknown native element. A component stored in ref() becomes a reactive proxy, which Vue warns about and unwraps.
solid answer
~40 s`is` as a string goes through a name lookup: the component's own explicit `name`, then locally registered components, then components registered with `app.component()`. `<script setup>` imports are plain variables, not registrations, so `:is="'GeneralPanel'"` matches nothing; the dynamic lookup does not warn and falls back to rendering the string as a native tag, leaving an empty unknown element. The fix is to bind the imported object. The second trap is storing that object in `ref()` or `reactive()`: a ref converts an object value into a deep reactive proxy, so the component definition itself gets proxied. In development Vue warns *"Vue received a Component that was made a reactive object"*, uses the raw object, and suggests `markRaw` or `shallowRef`. Keep a plain map of components and put only the active key in a `ref`.
code
vue · 19 lines<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import GeneralPanel from './GeneralPanel.vue'
// renders an empty <generalpanel> element: the import is not a registered name
const byName = ref('GeneralPanel')
// works, but warns in development: the definition becomes a reactive proxy
const proxied = ref(GeneralPanel)
// works without the warning: only .value is tracked
const active = shallowRef(GeneralPanel)
</script>
<template>
<component :is="byName" />
<component :is="proxied" />
<component :is="active" />
</template>go deeper
Remember to bind the imported component object to is in script setup, not a string with its name.
Explain the name lookup order, why unmatched strings silently become native tags, and why ref() deep-converts a component definition.
Recognise both failure modes in review, choose between a key-in-ref map, shallowRef and markRaw, and explain the runtime cost the warning points at.
Decide whether a codebase allows string-based component lookup at all, given that it bypasses tree-shaking friendly imports and fails silently.
## How Vue resolves the `is` value The template compiler hands the `is` binding of `<component>` to an internal resolve step at render time. The rules are short: 1. A **component object** (or a function component) is used as it is. No lookup happens. 2. A **string** is looked up as a component name, in this order: the current component's own explicit `name`, then components registered locally (the Options API `components` option), then components registered on the app with `app.component()`. The lookup accepts the name as written, camelized, or capitalized, so `'general-panel'` can match `GeneralPanel`. 3. A string that matches **nothing** is treated as an HTML tag name and rendered as a native element. Step 3 is the subtle one. For a static tag in a template, a missing component produces the development warning `Failed to resolve component`. The dynamic lookup behind `<component :is>` deliberately skips that warning, because a string there is allowed to be a tag like `'a'` or `'span'`. A typo or an unregistered name therefore fails **silently**. ## Why a name string fails in `<script setup>` In `<script setup>`, an import such as `import GeneralPanel from './GeneralPanel.vue'` is a local variable that the compiled template references directly. It is never added to a registry. So: - `<component :is="GeneralPanel">` works, because it passes the object; - `<component :is="'GeneralPanel'">` or `:is="current"` where `current` holds the string `'GeneralPanel'` finds no registered name and renders an empty, unknown `generalpanel` element. The string form only works for components registered with `app.component()`, for names in an Options API `components` option, or for native tags. ## Why a component inside `ref()` warns `ref()` is deep: when its value is an object, Vue converts it with `reactive()`. A component definition is an object, so `ref(GeneralPanel)` or a `reactive([...])` array of panels wraps the definition in a **reactive proxy**. Vue checks for this when it creates the vnode and, in development, warns: > Vue received a Component that was made a reactive object. This can lead to unnecessary performance overhead and should be avoided by marking the component with `markRaw` or using `shallowRef` instead of `ref`. It then unwraps the proxy with `toRaw` and renders normally, which is why the page seems to work. The cost is real, though: every property read on the definition goes through proxy traps and dependency tracking for data that never changes. ## Patterns compared | Pattern | Definitions proxied? | Verdict | |---|---|---| | Plain `const` map of components, `ref` holding the active key | No | Recommended | | `shallowRef(GeneralPanel)` | No, only `.value` is tracked | Fine when you must store the component itself | | `markRaw(GeneralPanel)` inside reactive state | No, marked to be skipped | Fine for lists of tab descriptors | | `ref(GeneralPanel)` or `reactive([GeneralPanel, ...])` | Yes | Triggers the warning | | String name of an import in `<script setup>` | Not applicable | Renders an unknown element | Practical guidance: - Keep definitions in module-level or setup-level constants; they do not need reactivity. - Store **which** tab is active (a string key or index), not the component, in reactive state. - If a descriptor list must live in reactive state, wrap each `component` field with `markRaw`. - When you inherit code that passes strings, check whether those names are actually registered; a silent native element is easy to miss in review. ## Example ```vue <script setup lang="ts"> import { markRaw, reactive, ref } from 'vue' import GeneralPanel from './GeneralPanel.vue' import PrivacyPanel from './PrivacyPanel.vue' const tabs = reactive([ { label: 'General', component: markRaw(GeneralPanel) }, { label: 'Privacy', component: markRaw(PrivacyPanel) }, ]) const active = ref(0) </script> <template> <button v-for="(tab, i) in tabs" :key="tab.label" @click="active = i"> {{ tab.label }} </button> <component :is="tabs[active].component" /> </template> ``` The descriptor list is reactive so labels can change, while each definition is marked raw and passed to `is` as an object.
- In Vue 3, when does passing a string to `<component :is>` work correctly?When the string is a name the lookup can find: a component registered with `app.component()`, one listed in an Options API `components` option, or the current component's own explicit `name`. It also works for a native tag such as `'a'` or `'button'`. It does not work for components that are only imported in `<script setup>`.
- Why does the reactive-component warning appear only in development, and does the page still work in production?The check and the warning are development-only diagnostics. The component still renders in both modes because Vue reads through the proxy; production simply pays the proxy and tracking overhead silently. Fix it at the source with `shallowRef` or `markRaw` rather than relying on the warning's absence in a production build.
saying these in an interview costs you the question
- Script setup imports are registered by name, so :is can take the name string.
- An unknown :is string always logs a Failed to resolve component warning.
- ref() only tracks .value, so a component stored in it is never proxied.
- The reactive-component warning means the component will not render.
- Component definitions must be reactive for <component :is> to switch.