Skip to content
TutorialAdvanced Vue

Vue warnings decoded: the ten you will meet first

Almost every [Vue warn] line names a problem and the component it came from. Here are the ten that turn up first, word for word, with the fix for each.

vuejs.school team25 Aug 20267 min readVue 3.5

Open the browser console on a Vue app in development and sooner or later you will see a yellow line that starts with [Vue warn]. Each one points at a real mistake, and most take two lines to fix once you know what the message means.

The warnings below are the ones you are most likely to meet in your first months with Vue 3.5, quoted exactly as Vue prints them and grouped by what went wrong. Search the page for the first words of your message to jump to its cause and fix.

How to read a warning

A warning has two parts: the message, then a component trace. Here is a real one:

[Vue warn]: Missing required prop: "count"
  at <Counter>
  at <App>

The first at line is the component where the problem was found. The lines below it are its parents, up to the root. Open the file named on the first line, unless the message is about something passed in, such as a prop or an attribute. Then the mistake is usually in the parent one line down, where the component is used.

A few of these checks also exist as type errors. If your project runs vue-tsc, the type checker for .vue files, it reports a wrong prop type, a missing prop, a write to a prop and an undeclared event before the browser ever sees them.

Props: invalid type, missing, or readonly

Take a small quantity picker. It receives the current quantity through v-model and a maximum through a prop.

components/QuantityPicker.vue
<script setup lang="ts">
defineProps<{ max: number }>()
const quantity = defineModel<number>({ required: true })
</script>

<template>
  <button type="button" :disabled="quantity >= max" @click="quantity++">
    Add one ({{ quantity }})
  </button>
</template>

Now use it carelessly: <QuantityPicker max="10" />. You get two warnings.

[Vue warn]: Invalid prop: type check failed for prop "max". Expected Number with value 10, got String with value "10".
[Vue warn]: Missing required prop: "modelValue"

The first happens because max="10" is a plain HTML attribute, and attributes are always strings. To pass the number 10, bind it with a colon: :max="10". The colon means "evaluate this as JavaScript".

The second warning names modelValue because that is the prop defineModel creates behind the scenes. The fix is to pass it: <QuantityPicker v-model="qty" :max="10" />. If a prop really is optional, make it optional in the type and give it a default, for example const { max = 10 } = defineProps<{ max?: number }>().

The third props warning appears when a component changes a prop it was given. Here is the picker written without defineModel, with quantity as an ordinary prop:

components/QuantityPicker.vueBreaks
<script setup lang="ts">
const props = defineProps<{ quantity: number, max: number }>()

function add() {
  props.quantity++
}
</script>
[Vue warn] Set operation on key "quantity" failed: target is readonly.

Props flow down from the parent, and the parent owns them. The fix is the defineModel version above: the child writes to quantity, and Vue sends an update:modelValue event so the parent changes its own value.

This warning looks different from the others. There is no colon after [Vue warn] and no component trace, because the check comes from Vue's reactivity system rather than from a component. To find the culprit, search your code for the key it names. In a component written with the Options API, the same mistake reads Attempting to mutate prop "quantity". Props are readonly.

Extraneous attributes and undeclared events

An attribute you pass to a component that is not a declared prop, such as type or placeholder, normally lands on the component's root element. That breaks when there is more than one root:

components/FieldRow.vueBreaks
<script setup lang="ts">
import { useId } from 'vue'

defineProps<{ label: string }>()
const id = useId()
</script>

<template>
  <label :for="id">{{ label }}</label>
  <input :id="id">
</template>

Used as <FieldRow label="Email" type="email" placeholder="you@example.com" />, it prints:

[Vue warn]: Extraneous non-props attributes (type, placeholder) were passed to component but could not be automatically inherited because component renders fragment or text or teleport root nodes.

A fragment is a template with several top-level elements. Vue cannot guess which one should get the attributes, so you tell it:

components/FieldRow.vueWorks
<script setup lang="ts">
import { useId } from 'vue'

defineOptions({ inheritAttrs: false })
defineProps<{ label: string }>()
const id = useId()
</script>

<template>
  <label :for="id">{{ label }}</label>
  <input :id="id" v-bind="$attrs">
</template>

inheritAttrs: false stops the automatic behaviour, and v-bind="$attrs" puts every extra attribute on the input. A sibling warning, Extraneous non-emits event listeners, means the same thing for an @click or other listener.

Events have their own warning: Component emitted event "remove" but it is neither declared in the emits option nor as an "onRemove" prop. Vue only checks this when the component declares at least one event, so the usual cause is a new $emit('remove', id) in the template that never made it into defineEmits. Add it to the list: defineEmits<{ toggle: [id: number], remove: [id: number] }>(). Declared events are also left out of $attrs, so an @click that your component emits itself is not also attached to the root element, where it would fire twice.

Unresolved components and missing templates

Failed to resolve component: BaseCard means the template uses a tag that Vue does not know. The message continues with If this is a native custom element, make sure to exclude it from component resolution via compilerOptions.isCustomElement. Check these causes in order:

  1. In <script setup>, the import is missing or its name differs from the tag. import Card from './BaseCard.vue' makes <Card> available, not <BaseCard>.
  2. The tag comes from a plugin that was never installed. Failed to resolve component: RouterLink almost always means app.use(router) is missing, or a test mounted the component without the router.
  3. It really is a custom element, such as a web component. Then tell the compiler to leave it alone:
vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: { isCustomElement: tag => tag.startsWith('my-') },
      },
    }),
  ],
})

With this setting, Vue leaves every tag that starts with my- to the browser. Use the prefix your custom elements share.

Component is missing template or render function: means Vue found the component but has nothing to draw. The usual cause is a .vue file with a script but no <template> block: a new file that so far has only its <script setup>, or a typo like <templte>, which Vue treats as an unknown custom block. Open the component named in the first at line and check its template tag. A completely empty .vue file never gets this far: Vue's compiler for .vue files rejects it with At least one <template> or <script> is required in a single file component.

Injection not found

injection "user" not found. means a component called inject('user') and no ancestor provided that key. A provider has to be above the component in the tree; a sibling or a child does not count. Typical causes are a typo in a string key, or a test that mounts the component on its own.

Use a typed symbol key so a typo becomes a type error, and decide what happens when nothing is provided:

composables/user.ts
import { inject, type InjectionKey, type Ref } from 'vue'

export interface User { name: string }
export const UserKey: InjectionKey<Ref<User | null>> = Symbol('user')

export function useUser() {
  const user = inject(UserKey)
  if (!user) throw new Error('useUser() needs a <UserProvider> above it')
  return user
}

Vue still prints its warning, now as injection "Symbol(user)" not found., and your error follows it with the name of the missing provider. That is faster to act on than an undefined three files later. In a Vue Test Utils test, pass the value with global: { provide: { [UserKey]: ref(null) } }.

Hydration mismatch between server and browser

Server-side rendering, as in Nuxt, renders the HTML on the server and then hydrates it in the browser: Vue walks the existing HTML and attaches itself instead of drawing it again. If the browser computes different output, you get:

[Vue warn]: Hydration text content mismatch on <p>Server time 10:00</p>
  - rendered on server: Server time 10:00
  - expected on client: Server time 10:01

Vue then logs Hydration completed but contains mismatches. The cause is a value that differs between the two runs: the current time, Math.random(), window.innerWidth, or something read from localStorage. Render a neutral value on both sides and fill in the browser-only value after mounting:

components/LoadedAt.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const loadedAt = ref<string | null>(null)

onMounted(() => {
  loadedAt.value = new Date().toLocaleTimeString()
})
</script>

<template>
  <p>Loaded {{ loadedAt ? `at ${loadedAt}` : 'just now' }}</p>
</template>

onMounted only runs in the browser, after hydration, so both runs render "Loaded just now" first. Other cases have their own fixes:

  • For element ids that must match, use useId().
  • In Nuxt, wrap a browser-only widget in <ClientOnly>.
  • When a difference is expected and harmless, Vue 3.5 accepts data-allow-mismatch="text" on the element to silence that one check.

Maximum recursive updates exceeded

This one is an error, not a warning, and it often shows up as Uncaught (in promise):

Maximum recursive updates exceeded in component <Counter>. This means you have a reactive effect that is mutating its own dependencies and thus recursively triggering itself. Possible sources include component template, render function, updated hook or watcher source function.

Vue stopped an endless loop: something changes the state it depends on, which runs it again, which changes the state again. Vue gives up after 100 rounds. The common causes are:

  • a function called from the template that also changes a ref
  • an onUpdated hook that changes state
  • a watcher that changes the value it watches

The fix is to keep reads and writes apart. Templates and computed only read. Changes happen in event handlers, or in a watcher that writes to a different ref than the one it watches.

Check it stuck3 questions from the Error handling and debugging quiz.
Practice now
Was this helpful?

Keep reading