Skip to content

Commit 0d930e4

Browse files
authored
feat: add createStoreHook for simplified React store integration (#27)
1 parent 8b98b6d commit 0d930e4

8 files changed

Lines changed: 312 additions & 7 deletions

File tree

.changeset/tame-suns-joke.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@codebelt/classy-store": minor
3+
---
4+
5+
feat: add createStoreHook for simplified React store integration

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ Enforced by Biome 2.4.0 (`biome.json` at repo root):
109109
|---|---|
110110
| `@codebelt/classy-store` | `createClassyStore`, `snapshot`, `subscribe`, `getVersion`, `shallowEqual`, `Snapshot` type |
111111
| `@codebelt/classy-store/collections` | `reactiveMap`, `reactiveSet`, `ReactiveMap` type, `ReactiveSet` type |
112-
| `@codebelt/classy-store/react` | `useClassyStore`, `useLocalStore` |
112+
| `@codebelt/classy-store/react` | `useClassyStore`, `useLocalStore`, `createStoreHook` |
113113
| `@codebelt/classy-store/vue` | `useClassyStore` (ShallowRef) |
114114
| `@codebelt/classy-store/svelte` | `toSvelteStore` (ClassyReadable) |
115115
| `@codebelt/classy-store/solid` | `useClassyStore` (signal getter) |

packages/classy-store/src/frameworks/react/react.test.tsx

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {afterEach, describe, expect, it, mock} from 'bun:test';
22
import {act, type ReactNode} from 'react';
33
import {createRoot} from 'react-dom/client';
44
import {createClassyStore} from '../../core/core';
5-
import {useClassyStore, useLocalStore} from './react';
5+
import {createStoreHook, useClassyStore, useLocalStore} from './react';
66

77
// ── Test harness ────────────────────────────────────────────────────────────
88

@@ -762,3 +762,98 @@ describe('useClassyStore — selector edge cases', () => {
762762
expect(renderCount).toHaveBeenCalledTimes(before);
763763
});
764764
});
765+
766+
// ── createStoreHook tests ──────────────────────────────────────────────────
767+
768+
describe('createStoreHook', () => {
769+
afterEach(teardown);
770+
771+
it('selector mode — renders and re-renders', async () => {
772+
class Counter {
773+
count = 0;
774+
increment() {
775+
this.count++;
776+
}
777+
}
778+
const store = createClassyStore(new Counter());
779+
const useCounter = createStoreHook(store);
780+
781+
function Display() {
782+
const count = useCounter((s) => s.count);
783+
return <div>{count}</div>;
784+
}
785+
786+
setup();
787+
render(<Display />);
788+
expect(container.textContent).toBe('0');
789+
790+
await act(async () => {
791+
store.increment();
792+
await flush();
793+
});
794+
795+
expect(container.textContent).toBe('1');
796+
});
797+
798+
it('auto-tracked (selectorless) mode', async () => {
799+
const store = createClassyStore({count: 0, name: 'hello'});
800+
const useStore = createStoreHook(store);
801+
const renderCount = mock(() => {});
802+
803+
function Display() {
804+
const snap = useStore();
805+
renderCount();
806+
return <div>{snap.count}</div>;
807+
}
808+
809+
setup();
810+
render(<Display />);
811+
expect(container.textContent).toBe('0');
812+
expect(renderCount).toHaveBeenCalledTimes(1);
813+
814+
await act(async () => {
815+
store.count = 5;
816+
await flush();
817+
});
818+
819+
expect(container.textContent).toBe('5');
820+
expect(renderCount).toHaveBeenCalledTimes(2);
821+
});
822+
823+
it('supports custom isEqual', async () => {
824+
const store = createClassyStore({items: [{id: 1, name: 'a'}]});
825+
const useStore = createStoreHook(store);
826+
const renderCount = mock(() => {});
827+
828+
function List() {
829+
const first = useStore(
830+
(s) => ({name: s.items[0]?.name}),
831+
(a, b) => a.name === b.name,
832+
);
833+
renderCount();
834+
return <div>{first.name}</div>;
835+
}
836+
837+
setup();
838+
render(<List />);
839+
expect(renderCount).toHaveBeenCalledTimes(1);
840+
841+
// Push new item — first item name unchanged → no re-render.
842+
await act(async () => {
843+
store.items.push({id: 2, name: 'b'});
844+
await flush();
845+
});
846+
847+
expect(renderCount).toHaveBeenCalledTimes(1);
848+
});
849+
850+
it('throws when given a non-store proxy', () => {
851+
const plainObj = {count: 0};
852+
853+
// No setup()/teardown needed — this throws before any DOM interaction.
854+
setup();
855+
expect(() => createStoreHook(plainObj as never)).toThrow(
856+
/not a store proxy/,
857+
);
858+
});
859+
});

packages/classy-store/src/frameworks/react/react.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,53 @@ function getAutoTrackSnapshot<T extends object>(
178178
return wrapped;
179179
}
180180

181+
// ── Bound store hook factory ─────────────────────────────────────────────────
182+
183+
/**
184+
* Create a pre-bound React hook for a specific store proxy.
185+
*
186+
* Eliminates the boilerplate of writing a wrapper around `useClassyStore`
187+
* for every store instance:
188+
*
189+
* ```ts
190+
* // Before:
191+
* export const catalogStore = createClassyStore(new CatalogStore());
192+
* export function useCatalogStore<S>(selector: (s: Snapshot<CatalogStore>) => S) {
193+
* return useClassyStore(catalogStore, selector);
194+
* }
195+
*
196+
* // After:
197+
* export const catalogStore = createClassyStore(new CatalogStore());
198+
* export const useCatalogStore = createStoreHook(catalogStore);
199+
* ```
200+
*
201+
* The returned hook supports both selector mode and auto-tracked (selectorless)
202+
* mode — identical to `useClassyStore`, but with the store already bound.
203+
*
204+
* @param proxyStore - A reactive proxy created by `createClassyStore()`.
205+
*/
206+
export function createStoreHook<T extends object>(proxyStore: T) {
207+
// Fail fast at creation time rather than on first render.
208+
getInternal(proxyStore);
209+
210+
function useStore(): Snapshot<T>;
211+
function useStore<S>(
212+
selector: (snap: Snapshot<T>) => S,
213+
isEqual?: (a: S, b: S) => boolean,
214+
): S;
215+
function useStore<S>(
216+
selector?: (snap: Snapshot<T>) => S,
217+
isEqual?: (a: S, b: S) => boolean,
218+
) {
219+
return useClassyStore(
220+
proxyStore,
221+
selector as (snap: Snapshot<T>) => S,
222+
isEqual,
223+
);
224+
}
225+
return useStore;
226+
}
227+
181228
// ── Component-scoped store ────────────────────────────────────────────────────
182229

183230
/**

website/docs/TUTORIAL.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,54 @@ function EditProfile() {
432432

433433
The `useEffect` cleanup ensures the persist subscription is removed and the store can be garbage collected when the component unmounts.
434434

435+
## Creating a Bound Hook with `createStoreHook`
436+
437+
When you have a module-level store, you typically also export a typed hook for it:
438+
439+
```ts
440+
// Before — manual boilerplate for each store
441+
import {createClassyStore} from '@codebelt/classy-store';
442+
import {useClassyStore} from '@codebelt/classy-store/react';
443+
import type {Snapshot} from '@codebelt/classy-store';
444+
445+
export const catalogStore = createClassyStore(new CatalogPageStore());
446+
447+
export function useCatalogStore<S>(
448+
selector: (snap: Snapshot<CatalogPageStore>) => S,
449+
): S {
450+
return useClassyStore(catalogStore, selector);
451+
}
452+
```
453+
454+
`createStoreHook` does this in one line:
455+
456+
```ts
457+
// After — one-liner
458+
import {createClassyStore} from '@codebelt/classy-store';
459+
import {createStoreHook} from '@codebelt/classy-store/react';
460+
461+
export const catalogStore = createClassyStore(new CatalogPageStore());
462+
export const useCatalogStore = createStoreHook(catalogStore);
463+
```
464+
465+
The returned hook supports both selector and auto-tracked modes, plus custom `isEqual` — identical to `useClassyStore`, but with the store already bound:
466+
467+
```tsx
468+
// Selector mode
469+
const count = useCatalogStore((state) => state.count);
470+
471+
// Auto-tracked mode
472+
const snap = useCatalogStore();
473+
474+
// Custom equality
475+
const data = useCatalogStore(
476+
(state) => ({count: state.count, loading: state.loading}),
477+
shallowEqual,
478+
);
479+
```
480+
481+
`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.
482+
435483
## Tips & Gotchas
436484

437485
### Mutate through methods, not from components

website/docs/index.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,56 @@ The factory runs once per mount. Subsequent re-renders reuse the same store inst
181181

182182
> See the [Local Stores](./TUTORIAL.md#local-stores) section in the Tutorial for persistence patterns and more examples.
183183
184+
### `createStoreHook(store)`
185+
186+
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.
187+
188+
```tsx
189+
import {createClassyStore} from '@codebelt/classy-store';
190+
import {createStoreHook} from '@codebelt/classy-store/react';
191+
192+
class CatalogPageStore {
193+
items: string[] = [];
194+
loading = false;
195+
196+
get count() { return this.items.length; }
197+
198+
addItem(item: string) { this.items.push(item); }
199+
}
200+
201+
const catalogStore = createClassyStore(new CatalogPageStore());
202+
export const useCatalogStore = createStoreHook(catalogStore);
203+
```
204+
205+
The returned hook supports both selector mode and auto-tracked mode — identical to `useClassyStore`, but with the store already bound:
206+
207+
```tsx
208+
// Selector mode
209+
function ItemCount() {
210+
const count = useCatalogStore((state) => state.count);
211+
return <span>{count} items</span>;
212+
}
213+
214+
// Auto-tracked mode
215+
function ItemList() {
216+
const snap = useCatalogStore();
217+
return <ul>{snap.items.map((item) => <li key={item}>{item}</li>)}</ul>;
218+
}
219+
220+
// Custom equality
221+
import {shallowEqual} from '@codebelt/classy-store';
222+
223+
function Summary() {
224+
const data = useCatalogStore(
225+
(state) => ({count: state.count, loading: state.loading}),
226+
shallowEqual,
227+
);
228+
return <div>{data.loading ? 'Loading...' : `${data.count} items`}</div>;
229+
}
230+
```
231+
232+
Validates at creation time — throws if the argument is not a store proxy.
233+
184234
### `snapshot(store)`
185235

186236
Creates a deeply frozen, immutable snapshot of the current state. Used internally by `useClassyStore` but also available directly.

website/static/llms-full.txt

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,39 @@ function Counter() {
189189

190190
The factory runs once per mount. Subsequent re-renders reuse the same store instance.
191191

192+
### `createStoreHook(store)`
193+
194+
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.
195+
196+
```ts
197+
import {createClassyStore} from '@codebelt/classy-store';
198+
import {createStoreHook} from '@codebelt/classy-store/react';
199+
200+
const catalogStore = createClassyStore(new CatalogPageStore());
201+
export const useCatalogStore = createStoreHook(catalogStore);
202+
```
203+
204+
The returned hook supports both selector mode and auto-tracked mode — identical to `useClassyStore`, but with the store already bound:
205+
206+
```tsx
207+
// Selector mode
208+
const count = useCatalogStore((state) => state.count);
209+
210+
// Auto-tracked mode
211+
const snap = useCatalogStore();
212+
213+
// Custom equality
214+
const data = useCatalogStore(
215+
(state) => ({count: state.count, loading: state.loading}),
216+
shallowEqual,
217+
);
218+
```
219+
220+
Validates at creation time — throws if the argument is not a store proxy.
221+
192222
### `snapshot(store)`
193223

194-
Creates a deeply frozen, immutable snapshot of the current state. Used internally by `useStore` but also available directly.
224+
Creates a deeply frozen, immutable snapshot of the current state. Used internally by `useClassyStore` but also available directly.
195225

196226
```typescript
197227
import {snapshot} from '@codebelt/classy-store';
@@ -894,6 +924,36 @@ function EditProfile() {
894924
}
895925
```
896926

927+
## Creating a Bound Hook with `createStoreHook`
928+
929+
When you have a module-level store, you typically also export a typed hook for it. `createStoreHook` does this in one line:
930+
931+
```ts
932+
import {createClassyStore} from '@codebelt/classy-store';
933+
import {createStoreHook} from '@codebelt/classy-store/react';
934+
935+
export const catalogStore = createClassyStore(new CatalogPageStore());
936+
export const useCatalogStore = createStoreHook(catalogStore);
937+
```
938+
939+
The returned hook supports both selector and auto-tracked modes, plus custom `isEqual` — identical to `useClassyStore`, but with the store already bound:
940+
941+
```tsx
942+
// Selector mode
943+
const count = useCatalogStore((state) => state.count);
944+
945+
// Auto-tracked mode
946+
const snap = useCatalogStore();
947+
948+
// Custom equality
949+
const data = useCatalogStore(
950+
(state) => ({count: state.count, loading: state.loading}),
951+
shallowEqual,
952+
);
953+
```
954+
955+
`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.
956+
897957
## Tips & Gotchas
898958

899959
### Non-proxyable types

website/static/llms.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
## Getting Started
88

99
- [Introduction & API Reference](https://codebelt.github.io/classy-store/docs/): Full API
10-
covering `createClassyStore`, `useStore`, `snapshot`, `subscribe`, `shallowEqual`,
11-
`reactiveMap`, `reactiveSet` (collections entry), and all patterns
10+
covering `createClassyStore`, `useClassyStore`, `createStoreHook`, `snapshot`, `subscribe`,
11+
`shallowEqual`, `reactiveMap`, `reactiveSet` (collections entry), and all patterns
1212
- [Tutorial](https://codebelt.github.io/classy-store/docs/TUTORIAL): Step-by-step guide —
13-
store definition, React hook modes (selector/auto-tracked), collections, inheritance,
14-
async patterns, local stores
13+
store definition, React hook modes (selector/auto-tracked), `createStoreHook`, collections,
14+
inheritance, async patterns, local stores
1515

1616
## Utilities
1717

0 commit comments

Comments
 (0)