Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tame-suns-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codebelt/classy-store": minor
---

feat: add createStoreHook for simplified React store integration
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
97 changes: 96 additions & 1 deletion packages/classy-store/src/frameworks/react/react.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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 <div>{count}</div>;
}

setup();
render(<Display />);
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 <div>{snap.count}</div>;
}

setup();
render(<Display />);
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 <div>{first.name}</div>;
}

setup();
render(<List />);
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/,
);
});
});
47 changes: 47 additions & 0 deletions packages/classy-store/src/frameworks/react/react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,53 @@ function getAutoTrackSnapshot<T extends object>(
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<S>(selector: (s: Snapshot<CatalogStore>) => 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<T extends object>(proxyStore: T) {
// Fail fast at creation time rather than on first render.
getInternal(proxyStore);

function useStore(): Snapshot<T>;
function useStore<S>(
selector: (snap: Snapshot<T>) => S,
isEqual?: (a: S, b: S) => boolean,
): S;
function useStore<S>(
selector?: (snap: Snapshot<T>) => S,
isEqual?: (a: S, b: S) => boolean,
) {
return useClassyStore(
proxyStore,
selector as (snap: Snapshot<T>) => S,
isEqual,
);
}
return useStore;
}

// ── Component-scoped store ────────────────────────────────────────────────────

/**
Expand Down
48 changes: 48 additions & 0 deletions website/docs/TUTORIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<S>(
selector: (snap: Snapshot<CatalogPageStore>) => 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
Expand Down
50 changes: 50 additions & 0 deletions website/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span>{count} items</span>;
}

// Auto-tracked mode
function ItemList() {
const snap = useCatalogStore();
return <ul>{snap.items.map((item) => <li key={item}>{item}</li>)}</ul>;
}

// Custom equality
import {shallowEqual} from '@codebelt/classy-store';

function Summary() {
const data = useCatalogStore(
(state) => ({count: state.count, loading: state.loading}),
shallowEqual,
);
return <div>{data.loading ? 'Loading...' : `${data.count} items`}</div>;
}
```

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.
Expand Down
62 changes: 61 additions & 1 deletion website/static/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions website/static/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down