From 34840f319f09fc7c3fc1f3da6461d66157b56237 Mon Sep 17 00:00:00 2001 From: a067334 Date: Fri, 1 May 2026 20:38:45 -0500 Subject: [PATCH 1/2] feat: add createStoreHook for simplified React store integration --- CLAUDE.md | 2 +- .../src/frameworks/react/react.test.tsx | 97 ++++++++++++++++++- .../src/frameworks/react/react.ts | 47 +++++++++ website/docs/TUTORIAL.md | 48 +++++++++ website/docs/index.md | 50 ++++++++++ website/static/llms-full.txt | 62 +++++++++++- website/static/llms.txt | 8 +- 7 files changed, 307 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c8c6129..e2c5caf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ Enforced by Biome 2.4.0 (`biome.json` at repo root): |---|---| | `@codebelt/classy-store` | `createClassyStore`, `snapshot`, `subscribe`, `getVersion`, `shallowEqual`, `Snapshot` type | | `@codebelt/classy-store/collections` | `reactiveMap`, `reactiveSet`, `ReactiveMap` type, `ReactiveSet` type | -| `@codebelt/classy-store/react` | `useClassyStore`, `useLocalStore` | +| `@codebelt/classy-store/react` | `useClassyStore`, `useLocalStore`, `createStoreHook` | | `@codebelt/classy-store/vue` | `useClassyStore` (ShallowRef) | | `@codebelt/classy-store/svelte` | `toSvelteStore` (ClassyReadable) | | `@codebelt/classy-store/solid` | `useClassyStore` (signal getter) | diff --git a/packages/classy-store/src/frameworks/react/react.test.tsx b/packages/classy-store/src/frameworks/react/react.test.tsx index 46b1ee9..e64d59b 100644 --- a/packages/classy-store/src/frameworks/react/react.test.tsx +++ b/packages/classy-store/src/frameworks/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 {useClassyStore, useLocalStore} from './react'; +import {createStoreHook, useClassyStore, useLocalStore} from './react'; // ── Test harness ──────────────────────────────────────────────────────────── @@ -762,3 +762,98 @@ describe('useClassyStore — selector edge cases', () => { expect(renderCount).toHaveBeenCalledTimes(before); }); }); + +// ── createStoreHook tests ────────────────────────────────────────────────── + +describe('createStoreHook', () => { + afterEach(teardown); + + it('selector mode — renders and re-renders', async () => { + class Counter { + count = 0; + increment() { + this.count++; + } + } + const store = createClassyStore(new Counter()); + const useCounter = createStoreHook(store); + + function Display() { + const count = useCounter((s) => s.count); + return
{count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('0'); + + await act(async () => { + store.increment(); + await flush(); + }); + + expect(container.textContent).toBe('1'); + }); + + it('auto-tracked (selectorless) mode', async () => { + const store = createClassyStore({count: 0, name: 'hello'}); + const useStore = createStoreHook(store); + const renderCount = mock(() => {}); + + function Display() { + const snap = useStore(); + renderCount(); + return
{snap.count}
; + } + + setup(); + render(); + expect(container.textContent).toBe('0'); + expect(renderCount).toHaveBeenCalledTimes(1); + + await act(async () => { + store.count = 5; + await flush(); + }); + + expect(container.textContent).toBe('5'); + expect(renderCount).toHaveBeenCalledTimes(2); + }); + + it('supports custom isEqual', async () => { + const store = createClassyStore({items: [{id: 1, name: 'a'}]}); + const useStore = createStoreHook(store); + const renderCount = mock(() => {}); + + function List() { + const first = useStore( + (s) => ({name: s.items[0]?.name}), + (a, b) => a.name === b.name, + ); + renderCount(); + return
{first.name}
; + } + + setup(); + render(); + expect(renderCount).toHaveBeenCalledTimes(1); + + // Push new item — first item name unchanged → no re-render. + await act(async () => { + store.items.push({id: 2, name: 'b'}); + await flush(); + }); + + expect(renderCount).toHaveBeenCalledTimes(1); + }); + + it('throws when given a non-store proxy', () => { + const plainObj = {count: 0}; + + // No setup()/teardown needed — this throws before any DOM interaction. + setup(); + expect(() => createStoreHook(plainObj as never)).toThrow( + /not a store proxy/, + ); + }); +}); diff --git a/packages/classy-store/src/frameworks/react/react.ts b/packages/classy-store/src/frameworks/react/react.ts index c0773e5..2339361 100644 --- a/packages/classy-store/src/frameworks/react/react.ts +++ b/packages/classy-store/src/frameworks/react/react.ts @@ -178,6 +178,53 @@ function getAutoTrackSnapshot( return wrapped; } +// ── Bound store hook factory ───────────────────────────────────────────────── + +/** + * Create a pre-bound React hook for a specific store proxy. + * + * Eliminates the boilerplate of writing a wrapper around `useClassyStore` + * for every store instance: + * + * ```ts + * // Before: + * export const catalogStore = createClassyStore(new CatalogStore()); + * export function useCatalogStore(selector: (s: Snapshot) => S) { + * return useClassyStore(catalogStore, selector); + * } + * + * // After: + * export const catalogStore = createClassyStore(new CatalogStore()); + * export const useCatalogStore = createStoreHook(catalogStore); + * ``` + * + * The returned hook supports both selector mode and auto-tracked (selectorless) + * mode — identical to `useClassyStore`, but with the store already bound. + * + * @param proxyStore - A reactive proxy created by `createClassyStore()`. + */ +export function createStoreHook(proxyStore: T) { + // Fail fast at creation time rather than on first render. + getInternal(proxyStore); + + function useStore(): Snapshot; + function useStore( + selector: (snap: Snapshot) => S, + isEqual?: (a: S, b: S) => boolean, + ): S; + function useStore( + selector?: (snap: Snapshot) => S, + isEqual?: (a: S, b: S) => boolean, + ) { + return useClassyStore( + proxyStore, + selector as (snap: Snapshot) => S, + isEqual, + ); + } + return useStore; +} + // ── Component-scoped store ──────────────────────────────────────────────────── /** diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md index fdf63aa..4f065a0 100644 --- a/website/docs/TUTORIAL.md +++ b/website/docs/TUTORIAL.md @@ -432,6 +432,54 @@ function EditProfile() { The `useEffect` cleanup ensures the persist subscription is removed and the store can be garbage collected when the component unmounts. +## Creating a Bound Hook with `createStoreHook` + +When you have a module-level store, you typically also export a typed hook for it: + +```ts +// Before — manual boilerplate for each store +import {createClassyStore} from '@codebelt/classy-store'; +import {useClassyStore} from '@codebelt/classy-store/react'; +import type {Snapshot} from '@codebelt/classy-store'; + +export const catalogStore = createClassyStore(new CatalogPageStore()); + +export function useCatalogStore( + selector: (snap: Snapshot) => S, +): S { + return useClassyStore(catalogStore, selector); +} +``` + +`createStoreHook` does this in one line: + +```ts +// After — one-liner +import {createClassyStore} from '@codebelt/classy-store'; +import {createStoreHook} from '@codebelt/classy-store/react'; + +export const catalogStore = createClassyStore(new CatalogPageStore()); +export const useCatalogStore = createStoreHook(catalogStore); +``` + +The returned hook supports both selector and auto-tracked modes, plus custom `isEqual` — identical to `useClassyStore`, but with the store already bound: + +```tsx +// Selector mode +const count = useCatalogStore((state) => state.count); + +// Auto-tracked mode +const snap = useCatalogStore(); + +// Custom equality +const data = useCatalogStore( + (state) => ({count: state.count, loading: state.loading}), + shallowEqual, +); +``` + +`createStoreHook` validates its argument immediately — if you pass something that isn't a store proxy, it throws at module load time rather than on first render. + ## Tips & Gotchas ### Mutate through methods, not from components diff --git a/website/docs/index.md b/website/docs/index.md index 4cd1d57..cf15655 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -181,6 +181,56 @@ The factory runs once per mount. Subsequent re-renders reuse the same store inst > See the [Local Stores](./TUTORIAL.md#local-stores) section in the Tutorial for persistence patterns and more examples. +### `createStoreHook(store)` + +Creates a pre-bound React hook for a specific store proxy. Eliminates the boilerplate of writing a typed wrapper around `useClassyStore` for every store instance. + +```tsx +import {createClassyStore} from '@codebelt/classy-store'; +import {createStoreHook} from '@codebelt/classy-store/react'; + +class CatalogPageStore { + items: string[] = []; + loading = false; + + get count() { return this.items.length; } + + addItem(item: string) { this.items.push(item); } +} + +const catalogStore = createClassyStore(new CatalogPageStore()); +export const useCatalogStore = createStoreHook(catalogStore); +``` + +The returned hook supports both selector mode and auto-tracked mode — identical to `useClassyStore`, but with the store already bound: + +```tsx +// Selector mode +function ItemCount() { + const count = useCatalogStore((state) => state.count); + return {count} items; +} + +// Auto-tracked mode +function ItemList() { + const snap = useCatalogStore(); + return
    {snap.items.map((item) =>
  • {item}
  • )}
; +} + +// Custom equality +import {shallowEqual} from '@codebelt/classy-store'; + +function Summary() { + const data = useCatalogStore( + (state) => ({count: state.count, loading: state.loading}), + shallowEqual, + ); + return
{data.loading ? 'Loading...' : `${data.count} items`}
; +} +``` + +Validates at creation time — throws if the argument is not a store proxy. + ### `snapshot(store)` Creates a deeply frozen, immutable snapshot of the current state. Used internally by `useClassyStore` but also available directly. diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt index 39f889b..2331835 100644 --- a/website/static/llms-full.txt +++ b/website/static/llms-full.txt @@ -189,9 +189,39 @@ function Counter() { The factory runs once per mount. Subsequent re-renders reuse the same store instance. +### `createStoreHook(store)` + +Creates a pre-bound React hook for a specific store proxy. Eliminates the boilerplate of writing a typed wrapper around `useClassyStore` for every store instance. + +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {createStoreHook} from '@codebelt/classy-store/react'; + +const catalogStore = createClassyStore(new CatalogPageStore()); +export const useCatalogStore = createStoreHook(catalogStore); +``` + +The returned hook supports both selector mode and auto-tracked mode — identical to `useClassyStore`, but with the store already bound: + +```tsx +// Selector mode +const count = useCatalogStore((state) => state.count); + +// Auto-tracked mode +const snap = useCatalogStore(); + +// Custom equality +const data = useCatalogStore( + (state) => ({count: state.count, loading: state.loading}), + shallowEqual, +); +``` + +Validates at creation time — throws if the argument is not a store proxy. + ### `snapshot(store)` -Creates a deeply frozen, immutable snapshot of the current state. Used internally by `useStore` but also available directly. +Creates a deeply frozen, immutable snapshot of the current state. Used internally by `useClassyStore` but also available directly. ```typescript import {snapshot} from '@codebelt/classy-store'; @@ -894,6 +924,36 @@ function EditProfile() { } ``` +## Creating a Bound Hook with `createStoreHook` + +When you have a module-level store, you typically also export a typed hook for it. `createStoreHook` does this in one line: + +```ts +import {createClassyStore} from '@codebelt/classy-store'; +import {createStoreHook} from '@codebelt/classy-store/react'; + +export const catalogStore = createClassyStore(new CatalogPageStore()); +export const useCatalogStore = createStoreHook(catalogStore); +``` + +The returned hook supports both selector and auto-tracked modes, plus custom `isEqual` — identical to `useClassyStore`, but with the store already bound: + +```tsx +// Selector mode +const count = useCatalogStore((state) => state.count); + +// Auto-tracked mode +const snap = useCatalogStore(); + +// Custom equality +const data = useCatalogStore( + (state) => ({count: state.count, loading: state.loading}), + shallowEqual, +); +``` + +`createStoreHook` validates its argument immediately — if you pass something that isn't a store proxy, it throws at module load time rather than on first render. + ## Tips & Gotchas ### Non-proxyable types diff --git a/website/static/llms.txt b/website/static/llms.txt index ab1209c..a85ea49 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -7,11 +7,11 @@ ## Getting Started - [Introduction & API Reference](https://codebelt.github.io/classy-store/docs/): Full API - covering `createClassyStore`, `useStore`, `snapshot`, `subscribe`, `shallowEqual`, - `reactiveMap`, `reactiveSet` (collections entry), and all patterns + covering `createClassyStore`, `useClassyStore`, `createStoreHook`, `snapshot`, `subscribe`, + `shallowEqual`, `reactiveMap`, `reactiveSet` (collections entry), and all patterns - [Tutorial](https://codebelt.github.io/classy-store/docs/TUTORIAL): Step-by-step guide — - store definition, React hook modes (selector/auto-tracked), collections, inheritance, - async patterns, local stores + store definition, React hook modes (selector/auto-tracked), `createStoreHook`, collections, + inheritance, async patterns, local stores ## Utilities From f546228d0fbd051d205b6b96c9429b9856ab102d Mon Sep 17 00:00:00 2001 From: a067334 Date: Fri, 1 May 2026 20:40:20 -0500 Subject: [PATCH 2/2] feat: add createStoreHook for simplified React store integration --- .changeset/tame-suns-joke.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tame-suns-joke.md diff --git a/.changeset/tame-suns-joke.md b/.changeset/tame-suns-joke.md new file mode 100644 index 0000000..79c16e0 --- /dev/null +++ b/.changeset/tame-suns-joke.md @@ -0,0 +1,5 @@ +--- +"@codebelt/classy-store": minor +--- + +feat: add createStoreHook for simplified React store integration