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
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