;
+ }
+
+ setup();
+ render();
+ 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);
+ });
+});
diff --git a/src/react/react.ts b/src/react/react.ts
index 8905c2b..832a47b 100644
--- a/src/react/react.ts
+++ b/src/react/react.ts
@@ -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';
@@ -161,3 +165,34 @@ function getAutoTrackSnapshot(
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 ;
+ * }
+ * ```
+ */
+export function useLocalStore(factory: () => T): T {
+ const [store] = useState(() => createClassyStore(factory()));
+ return store;
+}
diff --git a/website/docs/TUTORIAL.md b/website/docs/TUTORIAL.md
index f7bd64f..2b437b5 100644
--- a/website/docs/TUTORIAL.md
+++ b/website/docs/TUTORIAL.md
@@ -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 ;
+}
+```
+
+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 (
+
+ );
+}
+```
+
+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