Vue 3.5: reactive props, request cleanup, and stable SSR IDs

Use reactive props destructuring without losing updates, cancel obsolete requests, and give server-rendered controls consistent IDs.
Make a changing prop safe to use
A preview can show the wrong item even when both HTTP requests succeed: the user selects a new item, its response arrives first, and the older response replaces it. Vue 3.5 provides two parts of the solution: a reactive prop read that starts the next request, and watcher cleanup that cancels the previous one. Neither replaces checking the response before displaying it.
This example targets a developer who already uses script setup and wants to make that request boundary explicit. It uses Vue, the SFC compiler and the server renderer at 3.5.0, the version introduced in the September 2024 release. The endpoint and item names are synthetic. No customer application or production reliability result is represented.
Watch a getter and register cleanup before awaiting
The compiler keeps itemId reactive because it comes directly from defineProps() in script setup. The getter in watch(() => itemId, ...) lets Vue read that prop when tracking the watcher. Replacing it with watch(itemId, ...) is rejected by the tested 3.5.0 compiler: it identifies itemId as a destructured prop and asks for a getter. This is an observed compiler diagnostic, not just advice about a possible lost update.
The fixture supplies GET /api/demo/items/{id}, which returns an object with a string title or a controlled failure. The component is client-rendered. Its immediate watcher does not make fetched content part of an SSR response. A production screen that needs server-loaded data must use its application framework’s data-loading boundary.
<script setup>
import { ref, watch, onWatcherCleanup } from 'vue';
const { itemId = 'demo' } = defineProps({ itemId: String });
const title = ref('');
const status = ref('idle');
watch(() => itemId, async (id) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort());
title.value = '';
status.value = 'loading';
try {
const response = await fetch(`/api/demo/items/${encodeURIComponent(id)}`, {
signal: controller.signal,
});
if (!response.ok) throw new Error('Request failed');
const data = await response.json();
if (!data || typeof data.title !== 'string') {
throw new Error('Unexpected response');
}
if (!controller.signal.aborted) {
title.value = data.title;
status.value = 'ready';
}
} catch {
if (!controller.signal.aborted) status.value = 'error';
}
}, { immediate: true });
</script>
<template>
<p v-if="status === 'loading'" role="status">Loading preview…</p>
<p v-else-if="status === 'error'" role="alert">Preview unavailable.</p>
<h2 v-else-if="status === 'ready'">{{ title }}</h2>
</template>
Cancellation is cleanup, not an error message
Register onWatcherCleanup() before the first await. Vue associates that call with the active watcher during synchronous execution. The registered callback runs before the watcher repeats and when it stops. Here it aborts the previous fetch. The aborted checks keep that cancellation from publishing a title or an error for the obsolete request.
The browser fixture starts an old request and then changes the prop while the response is held open. The server observes that request close, the new response becomes the heading, and no cancellation error appears. Removing controller.abort() produces the useful negative control: releasing the old response after the new one overwrites the new heading. Removing only the aborted check in catch produces a different defect: an error banner appears while the new request is still pending.
Active failures are different. HTTP 503, malformed JSON and an object without title each display Preview unavailable. A subsequent valid response recovers the heading. Unmounting the component also closes its pending request. These cases exercise the shown branches. They do not establish an application-wide retry, authentication or offline policy.
I recommend clearing the previous heading when this preview starts loading, as the snippet does, so an old item is not mistaken for the selection being fetched. Retaining it with a visible stale indicator is a reasonable alternative when the interface needs continuity. Avoid a shared finally block that lets an obsolete request clear the active request’s loading state.
Use stable IDs for server-rendered controls
useId() gives each component instance an ID that is stable between server rendering and client hydration when they render the same component tree. The fixture renders two SearchField instances on the server, hydrates them in Chromium, then checks that their IDs are distinct, unchanged and correctly connected to their labels. Clicking each label focuses its own input, and the matching tree emits no hydration mismatch warning.
This uniqueness is scoped to an application. Vue 3.5.0 builds IDs from appContext.config.idPrefix and the instance’s counters. Separate Vue applications need distinct app.config.idPrefix values when their controls share a document. useId() is not a database identifier or a key for matching records across requests.
<script setup>
import { useId } from 'vue';
const inputId = useId();
</script>
<template>
<label :for="inputId">Search articles</label>
<input :id="inputId" name="query" type="search" />
</template>
Reproduce both the working boundary and its failures
Download the source and tests below. With Docker running and Python 3 installed, extract the archive, enter its frontend directory and run python3 scripts/verify-vue-35.py. The initial image build needs network access. The tests run with external networking disabled. Success exits zero and reports thirteen checks, four negative controls and the executed source hashes. The pinned environment uses Node 24.20.0 and Chromium 153.0.8010.12. No Vue project or credentials are required.
The reproduction compiles the exact two SFCs shown above and uses a real loopback HTTP server whose responses the test releases explicitly. It does not replace fetch with a stub or depend on which request happens to finish first. The separate SSR test uses the matching server renderer and hydrates its actual HTML with the browser build.
A third negative control changes the client-generated IDs while retaining the original server HTML. Vue reports the hydration mismatch. That failure is evidence to investigate the server/client tree or data. Suppressing the warning would not make their identities agree.
The tests establish these component boundaries with synthetic data. They do not measure performance, validate lazy hydration strategies, or replace integration tests for the production SSR framework, authorization rules and network environment. Keep those checks separate from the release’s general feature claims.
Reproduce the examples
The exact two Vue SFCs, a controlled HTTP endpoint, compiler and browser checks, SSR hydration, four negative controls, dependency lock and pinned browser image. Includes reproduction instructions.
Download the source and tests (.zip)
