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/short-mirrors-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codebelt/classy-store": patch
---

add `useLocalStore` hook for component-scoped reactive stores with tests and documentation
154 changes: 153 additions & 1 deletion src/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 {useStore} from './react';
import {useLocalStore, useStore} from './react';

// ── Test harness ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -370,3 +370,155 @@ describe('useStore — auto-tracked mode', () => {
expect(container.textContent).toBe('40');
});
});

// ── useLocalStore tests ─────────────────────────────────────────────────────

describe('useLocalStore', () => {
afterEach(teardown);

it('creates a component-scoped store and renders state', () => {
class Counter {
count = 42;
}

function Display() {
const store = useLocalStore(() => new Counter());
const count = useStore(store, (snap) => snap.count);
return <div>{count}</div>;
}

setup();
render(<Display />);
expect(container.textContent).toBe('42');
});

it('responds to mutations on the local store', async () => {
class Counter {
count = 0;
increment() {
this.count++;
}
}

let storeRef: Counter;

function Display() {
const store = useLocalStore(() => new Counter());
storeRef = store;
const count = useStore(store, (snap) => snap.count);
return <div>{count}</div>;
}

setup();
render(<Display />);
expect(container.textContent).toBe('0');

await act(async () => {
storeRef.increment();
await flush();
});

expect(container.textContent).toBe('1');
});

it('each component instance gets its own isolated store', () => {
class Counter {
count: number;
constructor(initial: number) {
this.count = initial;
}
}

function Display({initial}: {initial: number}) {
const store = useLocalStore(() => new Counter(initial));
const count = useStore(store, (snap) => snap.count);
return <div data-initial={initial}>{count}</div>;
}

setup();
render(
<>
<Display initial={10} />
<Display initial={20} />
</>,
);

const divs = container.querySelectorAll('div');
expect(divs[0].textContent).toBe('10');
expect(divs[1].textContent).toBe('20');
});

it('works with computed getters', async () => {
class Store {
count = 5;
get doubled() {
return this.count * 2;
}
setCount(value: number) {
this.count = value;
}
}

let storeRef: Store;

function Display() {
const store = useLocalStore(() => new Store());
storeRef = store;
const doubled = useStore(store, (snap) => snap.doubled);
return <div>{doubled}</div>;
}

setup();
render(<Display />);
expect(container.textContent).toBe('10');

await act(async () => {
storeRef.setCount(20);
await flush();
});

expect(container.textContent).toBe('40');
});

it('works with auto-tracked mode', async () => {
class Store {
name = 'hello';
count = 0;
}

let storeRef: Store;
const renderCount = mock(() => {});

function Display() {
const store = useLocalStore(() => new Store());
storeRef = store;
const snap = useStore(store);
renderCount();
return <div>{snap.name}</div>;
}

setup();
render(<Display />);
expect(container.textContent).toBe('hello');
expect(renderCount).toHaveBeenCalledTimes(1);

// Change name — accessed by component → should re-render.
await act(async () => {
storeRef.name = 'world';
await flush();
});

expect(container.textContent).toBe('world');
expect(renderCount).toHaveBeenCalledTimes(2);

// Change count — NOT accessed by component, but auto-tracked mode
// re-renders because the snapshot reference changes on any mutation.
// (Documented behavior — see "Set-then-revert" in TUTORIAL.md.)
await act(async () => {
storeRef.count = 99;
await flush();
});

expect(renderCount).toHaveBeenCalledTimes(3);
});
});
39 changes: 37 additions & 2 deletions src/react/react.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {createProxy, isChanged} from 'proxy-compare';
import {useCallback, useRef, useSyncExternalStore} from 'react';
import {subscribe as coreSubscribe, getInternal} from '../core/core';
import {useCallback, useRef, useState, useSyncExternalStore} from 'react';
import {
subscribe as coreSubscribe,
createClassyStore,
getInternal,
} from '../core/core';
import {snapshot} from '../snapshot/snapshot';
import type {Snapshot} from '../types';

Expand Down Expand Up @@ -161,3 +165,34 @@ function getAutoTrackSnapshot<T extends object>(
wrappedRef.current = wrapped;
return wrapped;
}

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

/**
* Create a component-scoped reactive store that lives for the lifetime of the
* component. When the component unmounts, the store becomes unreferenced and is
* garbage collected (all internal bookkeeping uses `WeakMap`).
*
* The factory function runs **once** per mount (via `useState` initializer).
* Each component instance gets its own isolated store.
*
* Use the returned proxy with `useStore()` to read state in the same component
* or pass it down via props/context to share within a subtree.
*
* @param factory - A function that returns a class instance (or plain object).
* Called once per component mount.
* @returns A reactive store proxy scoped to the component's lifetime.
*
* @example
* ```tsx
* function Counter() {
* const store = useLocalStore(() => new CounterStore());
* const count = useStore(store, s => s.count);
* return <button onClick={() => store.increment()}>{count}</button>;
* }
* ```
*/
export function useLocalStore<T extends object>(factory: () => T): T {
const [store] = useState(() => createClassyStore(factory()));
return store;
}
64 changes: 64 additions & 0 deletions website/docs/TUTORIAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,70 @@ class PostStore {

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.

## Local Stores

By default, stores are module-level singletons — shared across your entire app. For component-scoped state that is garbage collected on unmount, use `useLocalStore`.

### Basic usage

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

```tsx
import {useLocalStore, useStore} from '@codebelt/classy-store/react';

class CounterStore {
count = 0;
get doubled() { return this.count * 2; }
increment() { this.count++; }
}

function Counter() {
const store = useLocalStore(() => new CounterStore());
const count = useStore(store, (s) => s.count);

return <button onClick={() => store.increment()}>Count: {count}</button>;
}
```

The factory function (`() => new CounterStore()`) runs once per mount. Subsequent re-renders reuse the same store instance.

### Persisting a local store

`persist()` subscribes to the store, which keeps a reference alive. You must call `handle.unsubscribe()` on unmount to allow garbage collection.

```tsx
import {useEffect} from 'react';
import {useLocalStore, useStore} from '@codebelt/classy-store/react';
import {persist} from '@codebelt/classy-store/utils';

class FormStore {
name = '';
email = '';
setName(v: string) { this.name = v; }
setEmail(v: string) { this.email = v; }
}

function EditProfile() {
const store = useLocalStore(() => new FormStore());
// Auto-tracked mode — this component reads both name and email (see Decision guide).
const snap = useStore(store);

useEffect(() => {
const handle = persist(store, { name: 'edit-profile-draft' });
return () => handle.unsubscribe();
}, [store]);

return (
<form>
<input value={snap.name} onChange={(e) => store.setName(e.target.value)} />
<input value={snap.email} onChange={(e) => store.setEmail(e.target.value)} />
</form>
);
}
```

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

## Tips & Gotchas

### Mutate through methods, not from components
Expand Down