Signals in Vue (I): A Minimal Bridge to the Composition API

javascript dev.to

Quick Recap

In the previous chapters, we completed the integration with the React environment.
Now let's try introducing our signals system into a Vue environment.

Goal of This Article

Safely connect our signal / computed primitives to the Vue 3 Composition API, so they can be used directly inside templates while preserving the behavior of our own reactive graph:

  • push dirty-marking + pull recomputation
  • avoiding double dependency tracking
  • preventing scheduler conflicts

Design Principles

One-way bridge

Only synchronize the value into a Vue ref.
Do not feed Vue's reactivity back into your graph to avoid circular scheduling.

Clear lifecycle

Clean up createEffect and (if used) computed.dispose() inside onUnmounted.

Snapshot source

  • Use peek() for initialization (no tracking, lazy recomputation if necessary)
  • Use get() inside our effect (to establish dependencies)

Consistent mental model

The callback passed to useComputedRef must read signal.get() in order to establish dependencies.

If you're doing pure Vue computation, just use Vue's computed.


Who Depends on Whom (Vue Version)

  • Templates and watch* only observe Vue refs (via useSignalRef)
  • Our computed reads signal.get() inside the callback to establish dependencies

Implementing the Adapter

import { shallowRef, onUnmounted, type Ref } from "vue";
import { createEffect, onCleanup } from "../core/effect.js";
import { computed as coreComputed } from "../core/computed.js";

type Readable<T> = { get(): T; peek(): T };

// Map signal/computed to a Vue ref
// (tear-free; driven by our effect)
export function useSignalRef<T>(src: Readable<T>): Ref<T> {
  const r = shallowRef<T>(src.peek()) as Ref<T>; // initial snapshot (no tracking)

  const stop = createEffect(() => {
    // Read inside a tracking context so updates propagate
    r.value = src.get();

    onCleanup(() => {
      // optional extension point (e.g. cancel timers)
    });
  });

  onUnmounted(() => stop()); // unsubscribe when component unmounts
  return r;
}

// Create a computed inside the component lifecycle
// and expose it as a Vue ref
export function useComputedRef<T>(
  fn: () => T,
  equals: (a: T, b: T) => boolean = Object.is
): Ref<T> {
  // Important: fn must read signal.get() to establish dependencies
  const memo = coreComputed(fn, equals);

  const r = useSignalRef<T>({
    get: () => memo.get(),
    peek: () => memo.peek()
  });

  onUnmounted(() => memo.dispose?.());

  return r;
}
Enter fullscreen mode Exit fullscreen mode

Why shallowRef?

We already perform equality checks and caching inside the core.

Vue only needs to know whether the value changed.

Deep tracking should remain the responsibility of the core equality strategy (equals), not Vue.


Update and Cleanup Timing

  • Use peek() for the initial snapshot
  • Use get() inside the effect to establish dependencies
  • onUnmounted → stop() ensures no subscriptions remain

Usage Example (SFC)

Counter: signal + derived value

<script setup lang="ts">
import { signal } from "../core/signal.js";
import { useSignalRef, useComputedRef } from "./vue-adapter";

const countSig = signal(0);

const count = useSignalRef(countSig); // Vue ref
const doubled = useComputedRef(() => countSig.get() * 2); // dependency via .get()

const inc = () => countSig.set(v => v + 1);
</script>

<template>
  <p>{{ count }} / {{ doubled }}</p>
  <button @click="inc">+1</button>
</template>
Enter fullscreen mode Exit fullscreen mode

Selector: observing only part of an object

<script setup lang="ts">
import { signal } from "../core/signal.js";
import { useComputedRef } from "./vue-adapter";

const userSig = signal({ id: 1, name: "Ada", age: 37 });

// Only expose name
// If other fields change but name remains equal,
// the template will not re-render
const nameRef = useComputedRef(
  () => userSig.get().name,
  (a, b) => a === b
);
</script>

<template>
  <h2>{{ nameRef }}</h2>
</template>
Enter fullscreen mode Exit fullscreen mode

Module-Scoped vs Component-Scoped Computed

Component scope

Use useComputedRef.
It will automatically call dispose() when the component unmounts.

Module scope

If you create a global computed, make sure to manually call dispose() when it's no longer needed.


Interoperability with watch / watchEffect

Observing your signals or computed values

First convert them to a Vue ref with useSignalRef, then observe them with watch or watchEffect.

This ensures Vue only observes the value, without participating in your dependency graph.

const price = useSignalRef(priceSig);

watch(price, (nv, ov) => {
  console.log("price changed:", ov, "", nv);
});
Enter fullscreen mode Exit fullscreen mode

Do not read signal.get() directly inside watchEffect.

That would make Vue's dependency tracking join our graph, potentially causing unnecessary reruns and lifecycle conflicts.


Responsibility Boundaries

Data / business logic

Handled by our createEffect.

UI / DOM logic

Handled by Vue lifecycle hooks (onMounted, watch, etc.).

watch should observe the Vue ref returned by useSignalRef, not .get() directly.


Common Pitfalls

useComputedRef(() => ref.value * 2)

Problem
This is purely Vue computation and will not enter the core reactive graph.

Fix
Read signal.get() inside the callback.

If you only need Vue computation, use Vue's computed.


Reading signal.get() directly in templates or setup

Problem
This is only a snapshot and will not update automatically.

Fix
Expose it as a ref using useSignalRef before passing it to templates.


Driving the same data with both Vue refs and signals

Problem
This easily creates circular scheduling and unexpected reruns.

Fix
Define a single source of truth (recommended: signals), and let Vue only display the value.


When to Use What?

Expose core state to templates
useSignalRef(signalOrComputed)

Create derived values inside a component
useComputedRef(() => signal.get() + ...)

Pure Vue computation or display
→ Vue computed

Observe value changes
useSignalRefwatch / watchEffect

Avoid:

  • calling .get() inside Vue effects
  • reading ref.value inside useComputedRef

Conclusion

If you've been following the series from the beginning, this workflow should now feel quite familiar.

For our signals system, frameworks are ultimately responsible only for binding data to UI rendering.
Creating an adapter is mainly a matter of practice.

Compared with React's unique behavior, Vue's template-based rendering model is actually closer to our mental model.

As long as you remember the following:

  • Vue only displays values via refs
  • dependency tracking and caching remain in the signals system

This approach avoids double dependency tracking and scheduler conflicts, while preserving the core advantage of:
push dirty-marking + pull recomputation

You can now stably integrate signals into Vue.

In the next article, we'll complete the story by covering interoperability and more advanced scenarios.

Source: dev.to

arrow_back Back to Tutorials