Integrating Signals with Vue's Composition API
In this article, we delve into the integration of our signal system within Vue's environment, focusing on maintaining the integrity of our reactive graph while utilizing the Vue 3 Composition API.
Article Objective
The primary goal is to connect our signal and computed primitives to Vue's Composition API. This connection should allow direct template usage while maintaining the behavior of our reactive graph, including:
- Push dirty-marking
- Pull recomputation
- Avoiding double dependency tracking
- Preventing scheduler conflicts
Core Design Principles
One-Way Bridge
Synchronize only the value into a Vue ref, avoiding feeding Vue's reactivity back into your graph. This prevents circular scheduling problems.
Clear Lifecycle Management
Ensure cleanup by calling createEffect and disposing with computed.dispose() inside onUnmounted.
Snapshot Source
- Use
peek()for initialization, which avoids tracking and allows lazy recomputation. - Use
get()within effects to establish dependencies.
Consistent Mental Model
Callbacks passed to useComputedRef must read signal.get() to establish dependencies. Vue's computed should be used for pure Vue computations.
Who Depends on Whom (Vue Version)
- Templates and
watchfunctions observe Vue refs viauseSignalRef. - Computed values read
signal.get()to establish dependencies.
Implementing the Adapter
import { shallowRef, onUnmounted } from "vue";
import { createEffect } from "../core/effect.js";
import { computed as coreComputed } from "../core/computed.js";
export function useSignalRef(src) {
const r = shallowRef(src.peek());
const stop = createEffect(() => {
r.value = src.get();
});
onUnmounted(() => stop());
return r;
}
export function useComputedRef(fn, equals = Object.is) {
const memo = coreComputed(fn, equals);
const r = useSignalRef({
get: () => memo.get(),
peek: () => memo.peek(),
});
onUnmounted(() => memo.dispose?.());
return r;
}
Why Use shallowRef?
Equality checks and caching are handled by the core. Vue only needs to detect value changes, leaving deep tracking to core strategies.
Update and Cleanup Timing
- Use
peek()for initial snapshots. - Use
get()within effects for dependency establishment. onUnmounted → stop()ensures no lingering subscriptions.
Usage Examples
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);
const doubled = useComputedRef(() => countSig.get() * 2);
const inc = () => countSig.set(v => v + 1);
</script>
<template>
<p>{{ count }} / {{ doubled }}</p>
<button @click="inc">+1</button>
</template>
Selector: Observing 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 });
const nameRef = useComputedRef(() => userSig.get().name, (a, b) => a === b);
</script>
<template>
<h2>{{ nameRef }}</h2>
</template>
Scoping Computed Values
- Component Scope: Use
useComputedRef, which auto-disposes on unmount. - Module Scope: Manually call dispose when a global computed is no longer needed.
Interoperability with watch / watchEffect
Convert signals or computed values to a Vue ref using useSignalRef before observing them with watch or watchEffect.
Responsibility Boundaries
- Data/Business Logic: Managed by
createEffect. - UI/DOM Logic: Managed by Vue lifecycle hooks.
Common Pitfalls
- Avoid reading
signal.get()directly inside templates or setup. - Define a single source of truth for data, typically signals, to prevent circular scheduling.
Conclusion
This guide provides a structured approach to integrating signals with Vue's Composition API. By adhering to these principles, you can ensure smooth data binding and UI rendering, avoiding issues like double dependency tracking and scheduler conflicts. In our next installment, we will explore advanced interoperability scenarios.