DEV Community

Cover image for Signals in Vue (II): Interop, Async Patterns, SSR, and Common Pitfalls
Luciano0322
Luciano0322

Posted on

Signals in Vue (II): Interop, Async Patterns, SSR, and Common Pitfalls

Quick Overview

  • How watch / watchEffect should be split from our createEffect
  • How to avoid leftover subscriptions/computed nodes during component remounts (key changes)
  • 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).

    Use useSignalRef() first to bridge a signal / computed into a Vue ref, then use watch / 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
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

Data-layer side effects should still stay in our effect system

createEffect(() => {
  const id = productIdSig.get();
  fetch(`/api/p/${id}`).then(/* ... */); // business effect
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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?.());
}
Enter fullscreen mode Exit fullscreen mode

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 }));
});
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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;
    }
  };
}
Enter fullscreen mode Exit fullscreen mode
<script setup lang="ts">
const resource = toResource(user);
const data = resource.read(); // throws while pending, handled by Suspense
</script>

<template>
  <Profile :data="data" />
</template>
Enter fullscreen mode Exit fullscreen mode
<Suspense>
  <UserPanel />
  <template #fallback><Spinner /></template>
</Suspense>
Enter fullscreen mode Exit fullscreen mode
  • 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 useSignalRef comes from peek(), so you already have a stable value before hydration.
  • Subscription timing: if you want to be more conservative, you can establish the createEffect subscription only inside onMounted() 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;
}
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode

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 }));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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(/* ... */);
});
Enter fullscreen mode Exit fullscreen mode

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?.());
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)