Skip to content

Commit 14ffd85

Browse files
authored
feat: add useLocalStore hook for component-scoped reactive stores (#6)
* feat: add `useLocalStore` hook for component-scoped reactive stores with tests and documentation
1 parent 6e096da commit 14ffd85

File tree

4 files changed

+259
-3
lines changed

4 files changed

+259
-3
lines changed

.changeset/short-mirrors-play.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@codebelt/classy-store": patch
3+
---
4+
5+
add `useLocalStore` hook for component-scoped reactive stores with tests and documentation

src/react/react.test.tsx

Lines changed: 153 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 {useStore} from './react';
5+
import {useLocalStore, useStore} from './react';
66

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

@@ -370,3 +370,155 @@ describe('useStore — auto-tracked mode', () => {
370370
expect(container.textContent).toBe('40');
371371
});
372372
});
373+
374+
// ── useLocalStore tests ─────────────────────────────────────────────────────
375+
376+
describe('useLocalStore', () => {
377+
afterEach(teardown);
378+
379+
it('creates a component-scoped store and renders state', () => {
380+
class Counter {
381+
count = 42;
382+
}
383+
384+
function Display() {
385+
const store = useLocalStore(() => new Counter());
386+
const count = useStore(store, (snap) => snap.count);
387+
return <div>{count}</div>;
388+
}
389+
390+
setup();
391+
render(<Display />);
392+
expect(container.textContent).toBe('42');
393+
});
394+
395+
it('responds to mutations on the local store', async () => {
396+
class Counter {
397+
count = 0;
398+
increment() {
399+
this.count++;
400+
}
401+
}
402+
403+
let storeRef: Counter;
404+
405+
function Display() {
406+
const store = useLocalStore(() => new Counter());
407+
storeRef = store;
408+
const count = useStore(store, (snap) => snap.count);
409+
return <div>{count}</div>;
410+
}
411+
412+
setup();
413+
render(<Display />);
414+
expect(container.textContent).toBe('0');
415+
416+
await act(async () => {
417+
storeRef.increment();
418+
await flush();
419+
});
420+
421+
expect(container.textContent).toBe('1');
422+
});
423+
424+
it('each component instance gets its own isolated store', () => {
425+
class Counter {
426+
count: number;
427+
constructor(initial: number) {
428+
this.count = initial;
429+
}
430+
}
431+
432+
function Display({initial}: {initial: number}) {
433+
const store = useLocalStore(() => new Counter(initial));
434+
const count = useStore(store, (snap) => snap.count);
435+
return <div data-initial={initial}>{count}</div>;
436+
}
437+
438+
setup();
439+
render(
440+
<>
441+
<Display initial={10} />
442+
<Display initial={20} />
443+
</>,
444+
);
445+
446+
const divs = container.querySelectorAll('div');
447+
expect(divs[0].textContent).toBe('10');
448+
expect(divs[1].textContent).toBe('20');
449+
});
450+
451+
it('works with computed getters', async () => {
452+
class Store {
453+
count = 5;
454+
get doubled() {
455+
return this.count * 2;
456+
}
457+
setCount(value: number) {
458+
this.count = value;
459+
}
460+
}
461+
462+
let storeRef: Store;
463+
464+
function Display() {
465+
const store = useLocalStore(() => new Store());
466+
storeRef = store;
467+
const doubled = useStore(store, (snap) => snap.doubled);
468+
return <div>{doubled}</div>;
469+
}
470+
471+
setup();
472+
render(<Display />);
473+
expect(container.textContent).toBe('10');
474+
475+
await act(async () => {
476+
storeRef.setCount(20);
477+
await flush();
478+
});
479+
480+
expect(container.textContent).toBe('40');
481+
});
482+
483+
it('works with auto-tracked mode', async () => {
484+
class Store {
485+
name = 'hello';
486+
count = 0;
487+
}
488+
489+
let storeRef: Store;
490+
const renderCount = mock(() => {});
491+
492+
function Display() {
493+
const store = useLocalStore(() => new Store());
494+
storeRef = store;
495+
const snap = useStore(store);
496+
renderCount();
497+
return <div>{snap.name}</div>;
498+
}
499+
500+
setup();
501+
render(<Display />);
502+
expect(container.textContent).toBe('hello');
503+
expect(renderCount).toHaveBeenCalledTimes(1);
504+
505+
// Change name — accessed by component → should re-render.
506+
await act(async () => {
507+
storeRef.name = 'world';
508+
await flush();
509+
});
510+
511+
expect(container.textContent).toBe('world');
512+
expect(renderCount).toHaveBeenCalledTimes(2);
513+
514+
// Change count — NOT accessed by component, but auto-tracked mode
515+
// re-renders because the snapshot reference changes on any mutation.
516+
// (Documented behavior — see "Set-then-revert" in TUTORIAL.md.)
517+
await act(async () => {
518+
storeRef.count = 99;
519+
await flush();
520+
});
521+
522+
expect(renderCount).toHaveBeenCalledTimes(3);
523+
});
524+
});

src/react/react.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import {createProxy, isChanged} from 'proxy-compare';
2-
import {useCallback, useRef, useSyncExternalStore} from 'react';
3-
import {subscribe as coreSubscribe, getInternal} from '../core/core';
2+
import {useCallback, useRef, useState, useSyncExternalStore} from 'react';
3+
import {
4+
subscribe as coreSubscribe,
5+
createClassyStore,
6+
getInternal,
7+
} from '../core/core';
48
import {snapshot} from '../snapshot/snapshot';
59
import type {Snapshot} from '../types';
610

@@ -161,3 +165,34 @@ function getAutoTrackSnapshot<T extends object>(
161165
wrappedRef.current = wrapped;
162166
return wrapped;
163167
}
168+
169+
// ── Component-scoped store ────────────────────────────────────────────────────
170+
171+
/**
172+
* Create a component-scoped reactive store that lives for the lifetime of the
173+
* component. When the component unmounts, the store becomes unreferenced and is
174+
* garbage collected (all internal bookkeeping uses `WeakMap`).
175+
*
176+
* The factory function runs **once** per mount (via `useState` initializer).
177+
* Each component instance gets its own isolated store.
178+
*
179+
* Use the returned proxy with `useStore()` to read state in the same component
180+
* or pass it down via props/context to share within a subtree.
181+
*
182+
* @param factory - A function that returns a class instance (or plain object).
183+
* Called once per component mount.
184+
* @returns A reactive store proxy scoped to the component's lifetime.
185+
*
186+
* @example
187+
* ```tsx
188+
* function Counter() {
189+
* const store = useLocalStore(() => new CounterStore());
190+
* const count = useStore(store, s => s.count);
191+
* return <button onClick={() => store.increment()}>{count}</button>;
192+
* }
193+
* ```
194+
*/
195+
export function useLocalStore<T extends object>(factory: () => T): T {
196+
const [store] = useState(() => createClassyStore(factory()));
197+
return store;
198+
}

website/docs/TUTORIAL.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,70 @@ class PostStore {
367367

368368
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.
369369

370+
## Local Stores
371+
372+
By default, stores are module-level singletons — shared across your entire app. For component-scoped state that is garbage collected on unmount, use `useLocalStore`.
373+
374+
### Basic usage
375+
376+
`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.
377+
378+
```tsx
379+
import {useLocalStore, useStore} from '@codebelt/classy-store/react';
380+
381+
class CounterStore {
382+
count = 0;
383+
get doubled() { return this.count * 2; }
384+
increment() { this.count++; }
385+
}
386+
387+
function Counter() {
388+
const store = useLocalStore(() => new CounterStore());
389+
const count = useStore(store, (s) => s.count);
390+
391+
return <button onClick={() => store.increment()}>Count: {count}</button>;
392+
}
393+
```
394+
395+
The factory function (`() => new CounterStore()`) runs once per mount. Subsequent re-renders reuse the same store instance.
396+
397+
### Persisting a local store
398+
399+
`persist()` subscribes to the store, which keeps a reference alive. You must call `handle.unsubscribe()` on unmount to allow garbage collection.
400+
401+
```tsx
402+
import {useEffect} from 'react';
403+
import {useLocalStore, useStore} from '@codebelt/classy-store/react';
404+
import {persist} from '@codebelt/classy-store/utils';
405+
406+
class FormStore {
407+
name = '';
408+
email = '';
409+
setName(v: string) { this.name = v; }
410+
setEmail(v: string) { this.email = v; }
411+
}
412+
413+
function EditProfile() {
414+
const store = useLocalStore(() => new FormStore());
415+
// Auto-tracked mode — this component reads both name and email (see Decision guide).
416+
const snap = useStore(store);
417+
418+
useEffect(() => {
419+
const handle = persist(store, { name: 'edit-profile-draft' });
420+
return () => handle.unsubscribe();
421+
}, [store]);
422+
423+
return (
424+
<form>
425+
<input value={snap.name} onChange={(e) => store.setName(e.target.value)} />
426+
<input value={snap.email} onChange={(e) => store.setEmail(e.target.value)} />
427+
</form>
428+
);
429+
}
430+
```
431+
432+
The `useEffect` cleanup ensures the persist subscription is removed and the store can be garbage collected when the component unmounts.
433+
370434
## Tips & Gotchas
371435

372436
### Mutate through methods, not from components

0 commit comments

Comments
 (0)