diff --git a/.changeset/short-mirrors-play.md b/.changeset/short-mirrors-play.md new file mode 100644 index 0000000..e35d567 --- /dev/null +++ b/.changeset/short-mirrors-play.md @@ -0,0 +1,5 @@ +--- +"@codebelt/classy-store": patch +--- + +add `useLocalStore` hook for component-scoped reactive stores with tests and documentation diff --git a/src/react/react.test.tsx b/src/react/react.test.tsx index b57ba5a..30dcb9f 100644 --- a/src/react/react.test.tsx +++ b/src/react/react.test.tsx @@ -2,7 +2,7 @@ import {afterEach, describe, expect, it, mock} from 'bun:test'; import {act, type ReactNode} from 'react'; import {createRoot} from 'react-dom/client'; import {createClassyStore} from '../core/core'; -import {useStore} from './react'; +import {useLocalStore, useStore} from './react'; // ── Test harness ──────────────────────────────────────────────────────────── @@ -370,3 +370,155 @@ describe('useStore — auto-tracked mode', () => { expect(container.textContent).toBe('40'); }); }); + +// ── useLocalStore tests ───────────────────────────────────────────────────── + +describe('useLocalStore', () => { + afterEach(teardown); + + it('creates a component-scoped store and renders state', () => { + class Counter { + count = 42; + } + + function Display() { + const store = useLocalStore(() => new Counter()); + const count = useStore(store, (snap) => snap.count); + return
{count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('42'); + }); + + it('responds to mutations on the local store', async () => { + class Counter { + count = 0; + increment() { + this.count++; + } + } + + let storeRef: Counter; + + function Display() { + const store = useLocalStore(() => new Counter()); + storeRef = store; + const count = useStore(store, (snap) => snap.count); + return
{count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('0'); + + await act(async () => { + storeRef.increment(); + await flush(); + }); + + expect(container.textContent).toBe('1'); + }); + + it('each component instance gets its own isolated store', () => { + class Counter { + count: number; + constructor(initial: number) { + this.count = initial; + } + } + + function Display({initial}: {initial: number}) { + const store = useLocalStore(() => new Counter(initial)); + const count = useStore(store, (snap) => snap.count); + return
{count}
; + } + + setup(); + render( + <> + + + , + ); + + const divs = container.querySelectorAll('div'); + expect(divs[0].textContent).toBe('10'); + expect(divs[1].textContent).toBe('20'); + }); + + it('works with computed getters', async () => { + class Store { + count = 5; + get doubled() { + return this.count * 2; + } + setCount(value: number) { + this.count = value; + } + } + + let storeRef: Store; + + function Display() { + const store = useLocalStore(() => new Store()); + storeRef = store; + const doubled = useStore(store, (snap) => snap.doubled); + return
{doubled}
; + } + + setup(); + render(); + expect(container.textContent).toBe('10'); + + await act(async () => { + storeRef.setCount(20); + await flush(); + }); + + expect(container.textContent).toBe('40'); + }); + + it('works with auto-tracked mode', async () => { + class Store { + name = 'hello'; + count = 0; + } + + let storeRef: Store; + const renderCount = mock(() => {}); + + function Display() { + const store = useLocalStore(() => new Store()); + storeRef = store; + const snap = useStore(store); + renderCount(); + return
{snap.name}
; + } + + setup(); + render(); + expect(container.textContent).toBe('hello'); + expect(renderCount).toHaveBeenCalledTimes(1); + + // Change name — accessed by component → should re-render. + await act(async () => { + storeRef.name = 'world'; + await flush(); + }); + + expect(container.textContent).toBe('world'); + expect(renderCount).toHaveBeenCalledTimes(2); + + // Change count — NOT accessed by component, but auto-tracked mode + // re-renders because the snapshot reference changes on any mutation. + // (Documented behavior — see "Set-then-revert" in TUTORIAL.md.) + await act(async () => { + storeRef.count = 99; + await flush(); + }); + + expect(renderCount).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/react/react.ts b/src/react/react.ts index 8905c2b..832a47b 100644 --- a/src/react/react.ts +++ b/src/react/react.ts @@ -1,6 +1,10 @@ import {createProxy, isChanged} from 'proxy-compare'; -import {useCallback, useRef, useSyncExternalStore} from 'react'; -import {subscribe as coreSubscribe, getInternal} from '../core/core'; +import {useCallback, useRef, useState, useSyncExternalStore} from 'react'; +import { + subscribe as coreSubscribe, + createClassyStore, + getInternal, +} from '../core/core'; import {snapshot} from '../snapshot/snapshot'; import type {Snapshot} from '../types'; @@ -161,3 +165,34 @@ function getAutoTrackSnapshot( wrappedRef.current = wrapped; return wrapped; } + +// ── Component-scoped store ──────────────────────────────────────────────────── + +/** + * Create a component-scoped reactive store that lives for the lifetime of the + * component. When the component unmounts, the store becomes unreferenced and is + * garbage collected (all internal bookkeeping uses `WeakMap`). + * + * The factory function runs **once** per mount (via `useState` initializer). + * Each component instance gets its own isolated store. + * + * Use the returned proxy with `useStore()` to read state in the same component + * or pass it down via props/context to share within a subtree. + * + * @param factory - A function that returns a class instance (or plain object). + * Called once per component mount. + * @returns A reactive store proxy scoped to the component's lifetime. + * + * @example + * ```tsx + * function Counter() { + * const store = useLocalStore(() => new CounterStore()); + * const count = useStore(store, s => s.count); + * return ; + * } + * ``` + */ +export function useLocalStore(factory: () => T): T { + const [store] = useState(() => createClassyStore(factory())); + return store; +} diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md index f7bd64f..2b437b5 100644 --- a/website/docs/TUTORIAL.md +++ b/website/docs/TUTORIAL.md @@ -367,6 +367,70 @@ class PostStore { This means a component using `useStore(postStore, (store) => store.loading)` will re-render twice: once when loading starts, once when it ends. That's the correct behavior. +## Local Stores + +By default, stores are module-level singletons — shared across your entire app. For component-scoped state that is garbage collected on unmount, use `useLocalStore`. + +### Basic usage + +`useLocalStore` creates a reactive store scoped to the component's lifetime. Each component instance gets its own isolated store. When the component unmounts, the store is garbage collected. + +```tsx +import {useLocalStore, useStore} from '@codebelt/classy-store/react'; + +class CounterStore { + count = 0; + get doubled() { return this.count * 2; } + increment() { this.count++; } +} + +function Counter() { + const store = useLocalStore(() => new CounterStore()); + const count = useStore(store, (s) => s.count); + + return ; +} +``` + +The factory function (`() => new CounterStore()`) runs once per mount. Subsequent re-renders reuse the same store instance. + +### Persisting a local store + +`persist()` subscribes to the store, which keeps a reference alive. You must call `handle.unsubscribe()` on unmount to allow garbage collection. + +```tsx +import {useEffect} from 'react'; +import {useLocalStore, useStore} from '@codebelt/classy-store/react'; +import {persist} from '@codebelt/classy-store/utils'; + +class FormStore { + name = ''; + email = ''; + setName(v: string) { this.name = v; } + setEmail(v: string) { this.email = v; } +} + +function EditProfile() { + const store = useLocalStore(() => new FormStore()); + // Auto-tracked mode — this component reads both name and email (see Decision guide). + const snap = useStore(store); + + useEffect(() => { + const handle = persist(store, { name: 'edit-profile-draft' }); + return () => handle.unsubscribe(); + }, [store]); + + return ( +
+ store.setName(e.target.value)} /> + store.setEmail(e.target.value)} /> +
+ ); +} +``` + +The `useEffect` cleanup ensures the persist subscription is removed and the store can be garbage collected when the component unmounts. + ## Tips & Gotchas ### Mutate through methods, not from components