Quick Overview
- How
watch/watchEffectshould be split from ourcreateEffect - How to avoid leftover subscriptions/computed nodes during component remounts (
keychanges) - Two practical ways to handle async data, and how to optionally integrate with Vue Suspense
- Snapshot and subscription timing for SSR / Hydration
- Performance practices with equality comparison (
equals) and normalized writes - Common Vue pitfalls → corrected patterns
watch / watchEffect: who should observe what?
Guiding principles
On the Vue side, only observe values (
refs).
UseuseSignalRef()first to bridge asignal/computedinto a Vueref, then usewatch/watchEffect.For data-layer side effects (requests, caching, logging), use our
createEffect.
Do not mix Vue’s tracking system into our dependency graph.
Comparison example
Anti-pattern: calling .get() directly inside watchEffect (double tracking)
watchEffect(() => {
console.log(priceSig.get()); // ❌ Vue is now involved in our dependency graph
});
Correct: bridge it into a Vue ref first, then watch the value
const price = useSignalRef(priceSig);
watch(price, (nv, ov) => {
console.log("price:", ov, "→", nv);
});
Data-layer side effects should still stay in our effect system
createEffect(() => {
const id = productIdSig.get();
fetch(`/api/p/${id}`).then(/* ... */); // business effect
});
Remounts with key: lifecycle matters
This is conceptually similar to the React lifecycle scenarios discussed earlier. Vue also uses a VDOM-based mechanism, so the behavior is fundamentally the same:
when a list item or route changes its
key, the old subtree is unmounted and a new subtree is mounted.
If your computed value is created at module scope and never unsubscribed, upstream dependency edges can remain alive after the old subtree is gone.
Solution A: component-scoped useComputedRef
Dispose automatically on unmount
const subtotal = useComputedRef(() =>
cartSig.get().items.reduce((s, i) => s + i.qty * i.price, 0)
);
// `useComputedRef` will dispose it automatically on unmount
Solution B: containerize the lifecycle with a Provider
const StoreKey = Symbol() as InjectionKey<{ subtotal: ReturnType<typeof coreComputed> }>;
export function provideStore() {
const subtotal = coreComputed(/* ... */);
provide(StoreKey, { subtotal });
onUnmounted(() => subtotal.dispose?.());
}
Async data
Approach A: state-driven rendering
Let our data-layer effect manage the lifecycle. Vue only renders the three states.
export const userId = signal(1);
export const user = signal<{
status: "idle" | "loading" | "ok" | "error";
data?: User;
err?: any;
}>({ status: "idle" });
createEffect(() => {
const id = userId.get();
user.set({ status: "loading" });
fetch(`/api/user/${id}`)
.then((res) => res.json())
.then((data) => user.set({ status: "ok", data }))
.catch((err) => user.set({ status: "error", err }));
});
<script setup lang="ts">
const u = useSignalRef(user);
</script>
<template>
<Spinner v-if="u.status === 'loading'" />
<ErrorView v-else-if="u.status === 'error'" :err="u.err" />
<Profile v-else :data="u.data" />
</template>
Approach B: Suspense resource
Wrap the three-state model into a read() API. If the value is not ready, throw a Promise and let Vue Suspense handle it.
export function toResource<T>(src: { peek(): { status: string; data?: T; err?: any } }) {
let pending: Promise<void> | null = null;
return {
read(): T {
const s = src.peek();
if (s.status === "ok") return s.data!;
if (s.status === "error") throw s.err;
if (!pending) pending = new Promise(() => {});
// Remember to replace this with a real pending promise in actual usage.
throw pending;
}
};
}
<script setup lang="ts">
const resource = toResource(user);
const data = resource.read(); // throws while pending, handled by Suspense
</script>
<template>
<Profile :data="data" />
</template>
<Suspense>
<UserPanel />
<template #fallback><Spinner /></template>
</Suspense>
- If your project does not rely heavily on Suspense, Approach A is usually enough.
- If you already have Suspense infrastructure, Approach B can fit naturally into that pattern. Just make sure you use a real pending promise, not a fake placeholder forever.
SSR / Hydration: snapshot and subscription timing
- Snapshot: the initial value of
useSignalRefcomes frompeek(), so you already have a stable value before hydration. - Subscription timing: if you want to be more conservative, you can establish the
createEffectsubscription only insideonMounted()to avoid starting timers or requests during SSR.
SSR-compatible example
export function useSignalRefSSR<T>(src: Readable<T>): Ref<T> {
const r = shallowRef<T>(src.peek()) as Ref<T>;
let stop: (() => void) | undefined;
onMounted(() => {
stop = createEffect(() => {
r.value = src.get();
});
});
onUnmounted(() => stop?.());
return r;
}
The key idea is simple:
do not perform side effects during render, and start subscriptions only after mount so the SSR side does not accidentally schedule timers or network requests.
Equality strategy and performance: equals and normalized writes
useComputedRef(fn, equals) pushes the “skip update when values are equal” strategy down into the core signal mechanism. Vue only receives actual changes.
For arrays / objects, you can use strategies such as shallowEqual, keyedEqual, or similar custom comparisons.
const sorted = useComputedRef(
() => [...listSig.get()].sort((a, b) => a.id - b.id),
(a, b) => a.length === b.length && a.every((x, i) => x.id === b[i].id)
);
This means Vue only updates when the sorted result is meaningfully different.
You should also avoid unnecessary writes at the source:
userSig.set(prev => (prev.name === next ? prev : { ...prev, name: next }));
If nothing really changed, do not write.
That reduces wasted recomputation from the source of truth.
Common pitfalls and fixes
Reading ref.value inside useComputedRef
Symptom:
It becomes a purely Vue-side computation and no longer participates in our reactive graph. That means you lose lazy evaluation, caching, and our dependency tracking.
Fix:
Inside the callback, read from signal.get().
If it is meant to be a pure Vue computation, use Vue’s computed instead.
// ❌
const wrong = useComputedRef(() => vueRef.value * 2);
// ✅
const ok = useComputedRef(() => countSig.get() * 2);
Calling .get() directly in the template or in setup
Symptom:
You only get a snapshot once, and it will not update reactively.
Fix:
Expose it as a Vue ref via useSignalRef.
<!-- ❌ -->
<p>{{ countSig.get() }}</p>
<!-- ✅ -->
<p>{{ useSignalRef(countSig) }}</p>
Using watchEffect to read .get()
Symptom:
watchEffect reruns unexpectedly, lifecycle timing becomes harder to reason about, and it may even interfere with our own effect system.
Fix:
Convert the source into a Vue ref first with useSignalRef(), then use watch / watchEffect.
Or move that side effect back into our createEffect.
// ❌ Directly reading `.get()` pulls Vue tracking into our graph
watchEffect(() => {
console.log("price:", priceSig.get());
});
// ✅ Convert to Vue ref first, then watch the value
const price = useSignalRef(priceSig);
watch(price, (nv, ov) => {
console.log("price:", ov, "→", nv);
});
// ✅ If it is a data-layer side effect, use our effect system instead
createEffect(() => {
const id = productIdSig.get();
fetch(`/api/p/${id}`).then(/* ... */);
});
Module-scoped computed values that are never cleaned up
Symptom:
After route changes or key changes, the old page is gone, but some computations/subscriptions are still alive and may continue to trigger upstream updates.
Fix:
Bind derived values to the component lifecycle with useComputedRef, or manage them centrally in a Provider and dispose them explicitly.
// ❌ Module-scoped computed lives forever and may leave stale dependencies behind
export const subtotal = computed(() => a.get() + b.get());
// ✅ Component-scoped, auto-disposed on unmount
const subtotal = useComputedRef(() => aSig.get() + bSig.get());
// ✅ Containerized lifecycle management
const StoreKey = Symbol();
export function provideStore() {
const subtotal = coreComputed(() => aSig.get() + bSig.get());
provide(StoreKey, { subtotal });
onUnmounted(() => subtotal.dispose?.());
}
Expecting Vue <Transition> / animations to delay data writes
Symptom:
You assume <Transition> delays the effect of signal.set().
In reality, the data updates immediately. Dependents react immediately too; the animation only affects how the UI is displayed.
Fix:
Keep data updates immediate in signals. Put the transition logic in the display layer, or use a local draft/copy and only commit back to the signal when needed.
<!-- ❌ Thinking Transition delays the data write -->
<script setup lang="ts">
const q = useSignalRef(querySig);
function onInput(e: Event) {
querySig.set((e.target as HTMLInputElement).value); // still updates immediately
}
</script>
<template>
<input :value="q" @input="onInput" />
<Transition><Expensive :query="q" /></Transition>
</template>
Correct: delay only the displayed value
<script setup lang="ts">
import { shallowRef, watch } from "vue";
const q = useSignalRef(querySig);
const shown = shallowRef(q.value);
let t: any;
watch(q, (nv) => {
clearTimeout(t);
t = setTimeout(() => {
shown.value = nv;
}, 200);
});
</script>
<template>
<input :value="q" @input="e => querySig.set((e.target as HTMLInputElement).value)" />
<Transition><Expensive :query="shown" /></Transition>
</template>
Correct: use a draft and commit later
<script setup lang="ts">
import { ref, watch } from "vue";
const committed = useSignalRef(titleSig);
const draft = ref(committed.value);
watch(committed, v => {
draft.value = v; // keep local copy in sync with external updates
});
function save() {
titleSig.set(draft.value); // global update only on commit
}
</script>
<template>
<input v-model="draft" />
<button @click="save">Save</button>
</template>
Transition and animations only affect presentation, not when data is written.
- If you need visual delay, delay the displayed value.
- If you need commit-on-save semantics, use a local draft and write back later.
Closing thoughts
At this point, we have a one-way, predictable adapter:
- signals still own dependency tracking and lazy recomputation
- Vue is responsible only for rendering and UI lifecycle
I chose these two frameworks as demonstration targets. Why not keep going through more frameworks?
Because, as I mentioned in earlier articles, frontend frameworks generally handle UI in two broad styles:
- Template-based Bindings and directives in HTML-like templates, such as Vue / Angular / Svelte templates
- JSX-based UI expressed in JavaScript, such as React / Preact / Solid
The solutions shown here already cover those two directions. Other frameworks are usually variations of the same ideas, so I will stop here instead of repeating similar patterns.
Additional notes
-
Tagged template / HTML-in-JS: Lit,
htm, and similar tools (not JSX, and not the same as traditional template systems) - Compiled templates: Svelte, Marko (template syntax, but compiled into imperative DOM operations instead of using a VDOM)
- DOM-first / enhancement-oriented: htmx, Alpine, Stimulus (behavior attached to existing HTML, usually driven by attributes)
- Resumability: Qwik (often written in JSX, but with an execution model very different from React)
In the next article, we will return to more advanced Signal topics and revisit how to further optimize the core mechanism.
Top comments (0)