Vue
Connecting @ilokesto/store to Vue 3 with shallowRef and computed.
Vue
Vue 3's reactivity system is built around refs and computed values. shallowRef mirrors a store snapshot with a single reactive reference, and watchEffect (or watch) drives synchronisation. The cleanup is handled by onUnmounted.
Adapter
The minimal adapter creates a shallowRef, subscribes to the store, and returns a readonly reference.
import { shallowRef, readonly, onUnmounted } from "vue";
import { Store } from "@ilokesto/store";
export function useStore<T>(store: Store<T>) {
const state = shallowRef(store.getState());
const unsubscribe = store.subscribe(() => {
state.value = store.getState();
});
onUnmounted(unsubscribe);
return readonly(state);
}shallowRef is used rather than ref because the store state is already a snapshot value managed externally. Deep reactivity is not needed and would add overhead for large state trees. readonly prevents accidental direct mutation from template code.
Place this composable at the component level so onUnmounted has the correct lifecycle scope. Calling it outside a setup context will throw because onUnmounted requires an active component instance.
Selector pattern
Derive a computed slice with computed. The computed value updates only when the shallowRef changes, and Vue's dependency tracking ensures downstream effects re-run only for the field they read.
import { shallowRef, computed, readonly, onUnmounted } from "vue";
import { Store } from "@ilokesto/store";
export function useSelector<T, U>(
store: Store<T>,
selector: (state: T) => U,
) {
const state = shallowRef(store.getState());
const unsubscribe = store.subscribe(() => {
state.value = store.getState();
});
onUnmounted(unsubscribe);
return computed(() => selector(state.value));
}// Only recalculates when the count field changes.
const count = useSelector(counterStore, (s) => s.count);computed is lazy and cached. The selector runs only when the component reads the value and the upstream shallowRef has changed since the last read.
Multiple stores
Compose multiple composables in a single setup function. Each subscription is scoped independently.
<script setup lang="ts">
import { useSelector } from "./composables/useSelector";
import { authStore } from "./stores/auth";
import { themeStore } from "./stores/theme";
const user = useSelector(authStore, (s) => s.user);
const theme = useSelector(themeStore, (s) => s);
</script>
<template>
<header :data-theme="theme">
<span v-if="user">{{ user.name }}</span>
<span v-else>Sign in</span>
</header>
</template>A change in themeStore does not invalidate the authStore subscription.
TypeScript
The composable is fully generic. Vue's ComputedRef<U> carries the inferred type from the selector.
import { Store } from "@ilokesto/store";
type AuthState = {
user: { id: string; name: string } | null;
status: "anonymous" | "signed-in";
};
export const authStore = new Store<AuthState>({
user: null,
status: "anonymous",
});
// ComputedRef<{ id: string; name: string } | null>
const user = useSelector(authStore, (s) => s.user);
// ComputedRef<"anonymous" | "signed-in">
const status = useSelector(authStore, (s) => s.status);Wrap per-store composables to keep component code free of store imports:
// composables/useAuth.ts
export function useAuthUser() {
return useSelector(authStore, (s) => s.user);
}
export function useAuthStatus() {
return useSelector(authStore, (s) => s.status);
}Testing
Test composables with @vue/test-utils and mount using a minimal test component, or use effectScope to run the composable without a full component.
import { mount } from "@vue/test-utils";
import { defineComponent, nextTick } from "vue";
import { expect, test } from "vitest";
import { Store } from "@ilokesto/store";
import { useStore } from "./composables/useStore";
test("exposes store state to the template", async () => {
const store = new Store({ count: 0 });
const TestComponent = defineComponent({
setup() {
return { state: useStore(store) };
},
template: `<span>{{ state.count }}</span>`,
});
const wrapper = mount(TestComponent);
expect(wrapper.text()).toBe("0");
store.setState({ count: 3 });
await nextTick();
expect(wrapper.text()).toBe("3");
});
test("unsubscribes on unmount", () => {
const store = new Store({ count: 0 });
let calls = 0;
// Spy on subscribe to capture the listener.
const originalSubscribe = store.subscribe.bind(store);
const listeners: Array<() => void> = [];
store.subscribe = (fn) => {
listeners.push(fn);
return originalSubscribe(fn);
};
const TestComponent = defineComponent({
setup() {
return { state: useStore(store) };
},
template: `<span></span>`,
});
const wrapper = mount(TestComponent);
wrapper.unmount();
store.setState({ count: 1 });
// The listener should not fire after unmount.
expect(calls).toBe(0);
});Always await nextTick() after a store update before asserting DOM content, since Vue batches DOM updates asynchronously.