Vue Adapter Design
Low-level design for @stateloom/vue — the Vue framework adapter. Covers the shallowRef + core effect() bridging strategy, selector memoization, scope injection via Vue's provide/inject system, and the plugin architecture.
Overview
The Vue adapter bridges Subscribable<T> from @stateloom/core to Vue's reactivity system via shallowRef and StateLoom's effect(). It provides two composables (useSignal, useStore), a Vue plugin for application-level scope, and a ScopeProvider component for template-based scope injection. The adapter contains no business logic — it is pure bridging code (~120 lines total).
Bridging Strategy
The Vue adapter uses two primitives from different systems:
shallowRef<T>(Vue) — holds the value that Vue templates and computed properties react toeffect()(StateLoom core) — tracks dependencies in the StateLoom reactive graph and pushes updates to the ref
Why effect() Instead of subscribe()
Using subscribe() alone would miss computed-to-computed dependency chains. Core effect() participates in the full reactive graph — when a computed signal's dependencies change, the effect re-runs, reads the new value via .get(), and pushes it to the Vue ref. Using subscribe() would only catch direct notifications, missing the pull-based propagation that computed signals rely on.
Why shallowRef Instead of ref()
Vue's ref() deeply wraps nested objects in reactive proxies. For StateLoom-managed state, this would create conflicting reactivity — Vue's tracking on top of StateLoom's tracking. shallowRef only triggers Vue reactivity on reference changes, which is exactly what StateLoom produces (new references on state updates).
Implementation Details
useSignal
The simplest composable — bridges any Subscribable<T> to a Vue ref:
export function useSignal<T>(source: Subscribable<T>): Readonly<Ref<T>> {
const value = shallowRef<T>(source.get());
const dispose = effect(() => {
value.value = source.get();
return undefined;
});
onScopeDispose(dispose);
return value;
}Lifecycle: The effect() runs immediately (setting the initial value), then re-runs whenever any StateLoom dependency read by source.get() changes. onScopeDispose ties cleanup to Vue's component lifecycle — when the component unmounts or the Vue effect scope is disposed, the StateLoom effect is cleaned up.
Return type: Readonly<Ref<T>> prevents accidental .value = x assignments in templates. State should only be updated through the source signal, not by mutating the ref directly.
useStore (with Selector)
Adds selector memoization on top of the useSignal pattern:
export function useStore<T, U = T>(
store: Subscribable<T>,
selector?: (state: T) => U,
equals: (a: U, b: U) => boolean = Object.is,
): Readonly<Ref<U>> {
const select = selector ?? ((state: T) => state as unknown as U);
let currentValue = select(store.get());
const value = shallowRef<U>(currentValue);
const dispose = effect(() => {
const nextValue = select(store.get());
if (!equals(currentValue, nextValue)) {
currentValue = nextValue;
value.value = nextValue;
}
return undefined;
});
onScopeDispose(dispose);
return value;
}Equality check: The effect runs on every StateLoom state change, but the Vue ref is only updated when the selected value actually differs. This prevents unnecessary Vue re-renders when unrelated parts of the store state change.
currentValue closure variable: The equality check compares against currentValue (a closure variable) rather than value.value (the ref). Reading the ref would create a Vue dependency, causing the effect to also track the ref — creating a feedback loop. The closure variable keeps the StateLoom and Vue dependency graphs separate.
SSR Scope Integration
The Vue adapter provides three scope injection mechanisms, all sharing the same injection key:
const SCOPE_KEY: InjectionKey<Scope> = Symbol('stateloom-scope');stateloomPlugin
Application-level scope via Vue's plugin system:
export const stateloomPlugin: Plugin<[StateloomPluginOptions] | []> = {
install(app: App, options?: StateloomPluginOptions) {
if (options?.scope !== undefined) {
app.provide(SCOPE_KEY, options.scope);
}
},
};Usage: app.use(stateloomPlugin, { scope }). Safe to install without options for client-only apps.
provideScope / useScope
Composable-based injection for programmatic scope setup in setup():
function provideScope(scope: Scope): void {
provide(SCOPE_KEY, scope);
}
function useScope(): Scope | undefined {
return inject(SCOPE_KEY, undefined);
}ScopeProvider
A renderless component for template-based scope injection:
export const ScopeProvider = defineComponent({
name: 'ScopeProvider',
props: {
scope: {
type: Object as PropType<Scope>,
required: true,
},
},
setup(props, { slots }) {
provideScope(props.scope);
return () => slots['default']?.();
},
});The component renders only its default slot children — no wrapper DOM element.
Scope Injection Hierarchy
All three mechanisms use Vue's standard provide/inject system. Inner providers override outer ones following Vue's standard injection resolution. useScope() returns undefined if no scope was provided.
Design Decisions
Why a Plugin for Application-Level Scope
Vue's plugin system runs during createApp(), before any component renders. app.provide() makes the scope available to every component without requiring a root wrapper component. This is cleaner than requiring <ScopeProvider> at the app root and integrates with Vue's standard configuration pattern.
Why Three Scope Mechanisms
Different SSR scenarios call for different approaches:
- Plugin: Application-level scope for Nuxt/Vite SSR where the scope is configured in the app setup
provideScope(): Programmatic scope insetup()for dynamic scope creation (e.g., layout components)<ScopeProvider>: Template-based scope for scoping specific component subtrees
All three are thin wrappers over the same provide(SCOPE_KEY, scope) call, so they are interchangeable and composable.
Why Readonly<Ref<T>> Return Type
Returning Readonly<Ref<T>> instead of Ref<T> prevents accidental direct mutation of the ref in templates or composables. State changes should flow through the StateLoom source (signal, store, atom) to ensure middleware, devtools, and other subscribers see the change. The read-only type makes this a compile-time constraint.
Why the Effect Returns undefined
StateLoom's effect() expects a cleanup function return. Returning undefined explicitly signals that no per-run cleanup is needed — the effect only pushes values to the ref. The single onScopeDispose(dispose) handles the final cleanup when the component unmounts.
Performance Considerations
| Concern | Strategy | Cost |
|---|---|---|
| Vue deep reactivity | shallowRef avoids deep proxy wrapping | Zero overhead for nested state |
| Selector memoization | Equality check before ref update | O(1) equality check per state change |
| Closure variable | currentValue avoids reading the Vue ref | No Vue dependency feedback loop |
| Effect cleanup | onScopeDispose ties to Vue component lifecycle | Automatic — no manual cleanup |
| Plugin installation | Single app.provide() call | O(1) per application |
| Adapter size | ~120 lines total | Tree-shakeable per composable |
Cross-References
- Adapters Overview — cross-cutting adapter patterns and adapter contract
- Architecture Overview — where adapters fit in the layer structure
- Core Design —
Subscribable<T>contract andeffect()internals - Store Design —
StoreApi<T>thatuseStoreconsumes - Atom Design — atom APIs consumed via
useSignal - Proxy Design — proxy APIs consumed via
useSignal - API Reference:
@stateloom/vue— consumer-facing documentation - Vue Guide — framework adoption guide