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 (viauseSignalRef) - Our
computedreadssignal.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;
}
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>
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>
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);
});
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
→ useSignalRef → watch / watchEffect
Avoid:
- calling
.get()inside Vue effects - reading
ref.valueinsideuseComputedRef
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.