Skip to the solution
MediumFrontend10 min read

useId() vs getCurrentInstance() — how do you generate SSR-safe IDs in Vue 3.5?

686 views

The problem

Question presented to candidate: "Our form inputs need unique ids so <label for> and aria-describedby work. Someone built them from getCurrentInstance().uid, and since we added SSR we get hydration warnings. What does useId() in Vue 3.5 do differently, and how would you fix this?"

What a strong answer should cover:

  • useId() (Vue 3.5) returns an id that is unique per app and identical on server and client, because it is derived from the component's position in the tree, not from a global counter.
  • getCurrentInstance().uid is a module-level counter of every instance ever created; on a server it keeps growing across requests, so the server and the fresh client disagree, causing hydration mismatches.
  • getCurrentInstance() is an internal-leaning API that the docs discourage in application code.
  • app.config.idPrefix changes the v prefix, useful when several Vue apps share a page.
  • useId() must be called in setup; each call in the same component gives the next id; async boundaries get their own segment so ids stay stable.
  • Derive sub-ids from one call (${id}-hint) rather than calling it many times; never use Math.random() or Date.now() for ids in SSR.

Clarifying questions expected:

  • "Is there more than one Vue app mounted on the page, for example micro-frontends?"
  • "Are we on Vue 3.5 or later? Before that, Nuxt's useId or a provide-based counter was the workaround."

Code / implementation expected: Yes — a field component using useId(), ideally shown through SSR and hydration.

  • useId
  • ssr
  • a11y

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

10 min read · 11 sections

Read the code

VueForm fields with useId() next to a uid-based id (render it on the client and with SSR)
<script setup>
import { defineComponent, h, useId, getCurrentInstance, ref } from "vue";

// SSR-safe: useId() derives the id from the component position in the tree.
const Field = defineComponent({
  props: { label: String },
  setup(props) {
    const id = useId();
    return () => h("div", { class: "field" }, [
      h("label", { for: id }, props.label),
      h("input", { id, "aria-describedby": `${id}-hint` }),
      h("small", { id: `${id}-hint` }, ` (${id})`),
    ]);
  },
});

// Not SSR-safe: uid is a global counter of every instance ever created.
const UidField = defineComponent({
  props: { label: String },
  setup(props) {
    const id = `field-${getCurrentInstance().uid}`;
    return () => h("div", { class: "field" }, [
      h("label", { for: id }, props.label),
      h("input", { id }),
      h("small", ` (${id})`),
    ]);
  },
});

const extra = ref(false);
</script>

<template>
  <Field label="Email" />
  <Field label="Password" />
  <UidField label="Nickname" />
  <button @click="extra = !extra">toggle extra field</button>
  <Field v-if="extra" label="Extra" />
</template>
Three SSR requests and a hydration: useId stays stable, uid-based ids drift and mismatch
// ssr.mjs: render the same App three times, as three requests would
import { createSSRApp } from "vue";
import { renderToString } from "@vue/server-renderer";
import { writeFileSync } from "fs";
import App from "./App.vue";
const ids = (html) => [...html.matchAll(/<input id="([^"]+)"/g)].map((m) => m[1]).join(", ");
for (const req of [1, 2, 3]) {
  const app = createSSRApp(App);              // a fresh app per request, as SSR requires
  if (req === 3) app.config.idPrefix = "checkout";
  const html = await renderToString(app);
  console.log(`request ${req}${req === 3 ? " (idPrefix=checkout)" : ""}: input ids = ${ids(html)}`);
  if (req === 2) writeFileSync("server.html", html);
}

// hydrate.mjs: hydrate the HTML that request 2 produced, in a fresh client
import { createSSRApp, nextTick } from "vue";
import { readFileSync } from "fs";
import App from "./App.vue";
const el = document.querySelector("#app");
el.innerHTML = readFileSync("server.html", "utf8");    // the HTML that request 2 sent
const app = createSSRApp(App);
app.mount(el);                                          // hydrate, not re-render
await nextTick();
console.log("client ids after hydration:", [...el.querySelectorAll("input")].map((i) => i.id).join(", "));
el.querySelector("button").click(); await nextTick();
console.log("toggled extra field, its id:", el.querySelectorAll("input")[3].id);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 42 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextMedium

What is defineExpose?

Continue