skip to content

In a Vue 3.5 SSR app, why should a form component generate its label and input ids with useId() instead of Math.random() or a counter?

level: middleimportance: should knowfreq 40%

answer

  1. ids appear in attributes
  2. random differs per run
  3. server counter outlives one request
  4. id derived from the component tree
  5. app.config.idPrefix for several apps

basics

~20 s

Random values and module counters produce different ids on the server and in the browser, so id and for attributes mismatch during hydration. useId, added in Vue 3.5, derives the id from the component's place in the app tree, so both renders agree.

solid answer

~50 s

An SSR component renders on the server and again during hydration, so any id it invents must come out identical both times. `Math.random()` never does. A module-level counter does not either: the server process is long-lived, so the counter keeps climbing across requests, while each browser starts it again from zero. Both give attribute mismatch warnings, and because Vue treats attribute mismatches as check-only, the DOM and the client's idea of the id can drift apart. `useId()` (Vue 3.5+) builds the id from the app's `idPrefix` (default `v`) plus counters tied to the component instance tree, which is the same on both sides; async boundaries get their own sub-prefix so async resolution order does not shift ids. Call it synchronously in `setup`, not inside `computed`, and set `app.config.idPrefix` when several Vue apps share one page.

code

vue · 15 lines
vue
<script setup lang="ts">
import { useId } from 'vue'

defineProps<{ label: string; hint?: string }>()
const model = defineModel<string>()

const id = useId()
const hintId = `${id}-hint`
</script>

<template>
  <label :for="id">{{ label }}</label>
  <input :id="id" v-model="model" :aria-describedby="hint ? hintId : undefined" />
  <p v-if="hint" :id="hintId">{{ hint }}</p>
</template>

go deeper

for a junior

Recall that ids must be identical in the server HTML and the client render, and that useId() from Vue 3.5 produces them.

for a middle

Explain why random values and module counters diverge, how useId() derives ids from the component tree, and the setup-only and no-computed rules.

for a senior

Discuss attribute mismatches being check-only, async boundaries keeping ids stable, and idPrefix when micro-frontends put several Vue apps on one page.

for a principal

Decide which ids are presentation-local (useId) and which are domain identifiers that must stay stable across loads and systems, and keep the two from being conflated.

## Why ids are a hydration problem Accessible forms need ids: `<label for>` must match an `<input id>`, and `aria-describedby` must point at a hint element's `id`. In a server-rendered Vue 3 app these attributes are produced **twice**, once into the server HTML and once by the client render that hydrates it. Hydration expects the two to agree. Anything that generates ids from state outside the component tree breaks that expectation. ### `Math.random()` or `crypto.randomUUID()` Different on every call, so the server's id and the client's id always differ. In development, Vue reports an **attribute mismatch** on the element. ### A module-level counter ```ts let next = 0 export const makeId = () => `field-${next++}` ``` This looks deterministic but is not: - The **server** loads the module once and keeps it for its whole life, so the counter keeps rising across every request, including concurrent ones. - Each **browser** loads the module fresh and starts at `0`. The first visitor after a deploy may get matching ids; everyone after that gets mismatches. (Module state shared between requests is a broader SSR hazard in its own right.) ## What an attribute mismatch actually does Vue's attribute, class and style mismatch warnings end with a note that the mismatch is **check-only**: the DOM will not be rectified in production because of the performance cost. In practice, attributes that Vue patches as declared dynamic props may still receive the client value, while others keep the server value, and in production without the details flag Vue does not even check them. The result you cannot rely on: code that later queries `document.getElementById(id)` or builds an `aria-*` reference from the client-side id may not find what the DOM contains. The only safe design is an id that is the same on both sides. ## How `useId()` solves it `useId()` was added in **Vue 3.5**, together with `app.config.idPrefix`. In the 3.5 source it: 1. reads the **current component instance** (so it must be called during `setup`); 2. starts from `app.config.idPrefix`, or `v` when none is set; 3. appends counters that live on the component instance tree, incremented per call. Because the server and the client render the same tree in the same order, the n-th call in a given component produces the same string on both sides. Key properties: - **Unique per application:** several calls in one component give different ids, and several instances of the same component give different ids. - **Async-safe:** components with async `setup()`, components with `serverPrefetch`, and async components start a new sub-prefix, so the order in which async work resolves does not reshuffle ids. - **Selector-safe:** generated ids can be used in CSS selectors. ## Rules of use | Situation | What to do | |---|---| | Normal component | `const id = useId()` at the top level of `<script setup>` | | Several related ids | derive them: `` `${id}-hint` ``, `` `${id}-error` `` | | Items in a `v-for` | combine one `useId()` with each item's own stable key | | Two Vue apps on one page | give each a distinct `app.config.idPrefix` | | Inside `computed()` | do not call it there; the docs warn it can cause instance conflicts | | Outside any component | it returns an empty string and warns in development | ## What `useId()` does not cover - Ids that must be **stable across page loads** or meaningful to outside systems (analytics, deep links): use data from your domain instead. - Ids for components created **outside** the rendered tree: without an active instance, `useId()` has nothing to derive from.

  • How do you give ids to every row of a `v-for` list with `useId()`?
    Call `useId()` once in `setup`, then combine it with each item's stable key in the template, for example `` `${id}-${item.id}` ``. `useId()` is a setup-time call that returns one id per call, so calling it inside the template loop is not the pattern; the item key provides the per-row uniqueness.
  • Why does `useId()` stay stable when some components have async `setup()`?
    Async components, components with async `setup()` and components with `serverPrefetch` mark an async boundary that starts a fresh sub-prefix for ids below it. Ids inside the boundary no longer depend on when sibling async work resolved, so the server and the client, which may resolve in different orders, still agree.
  • What happens if you call `useId()` inside a `computed`?
    The docs warn against it because it may cause instance conflicts: the getter can run when a different component is current, or outside setup entirely. Call `useId()` once in `setup` and reference the result from the computed.

saying these in an interview costs you the question

  • A module-level counter is deterministic, so it is SSR-safe
  • useId() generates a fresh random id on every render
  • useId() returns a globally unique UUID
  • useId() can be called anywhere, including event handlers
  • Ids from two Vue apps on one page can never collide