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
18 changes: 18 additions & 0 deletions .changeset/public-utility-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@codebelt/classy-store': minor
---

**Upgrade required from 0.7.x.** Add observable utility status and simplify the public API. See the [0.8.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_8) for migration examples.

- History and persistence handles expose `getSnapshot()`, `getServerSnapshot()`, and `subscribe(listener)`. Use the new `useHistoryStatus(history)` React hook for undo/redo availability and recording state, and `usePersistStatus(handle)` for hydration, expiry, and persistence errors. Both expose disposal status and update even when store data does not change. Each hook accepts its matching handle type and shares an internal subscription implementation.
- Explicit `save()`, `clear()`, and `rehydrate()` calls reject on failure after reporting through `onError` and status. These methods take no options; catch rejected promises in event handlers and effects. Automatic writes and initial hydration handle reported failures internally. Failed calls do not block later queued writes. Calls on disposed handles and explicit queued operations cancelled by disposal reject.
- Persistence cleanup is now `dispose()`. The `unsubscribe()` method has been removed; update effect cleanups and other teardown calls to `handle.dispose()`. Repeated disposal is safe and still stops listeners and timers, ignores pending reads, and skips queued writes that have not started.
- Remove the public `getVersion` export. Use `subscribe()` for notifications or compare `snapshot()` references for change detection. The version helper remains internal for persistence and core bookkeeping.
- Keep the shared `UtilityStatusSource` type internal. Use the exported `HistoryHandle`, `PersistHandle`, `HistoryStatus`, and `PersistStatus` types in consumers. Also export `PersistOperation` from the utilities entry.
- Import `prepareStoreForHydration` from `@codebelt/classy-store/react/server` on both server and client. The duplicate export from `@codebelt/classy-store/react` has been removed.
- Persistence transforms infer their value type from `key`. Serializers receive that property's readonly snapshot; deserializers must return the matching mutable property type. Existing transforms that return another property's type or mutate snapshot inputs now fail TypeScript checks. Persistence flags are readonly in the public types, matching their existing runtime getters.
- Preserve explicit history pause state when undo/redo finishes. Update React and Next.js examples to subscribe to utility status directly.

`hydrated` and `isHydrated` indicate that a hydration attempt has settled, including failure or an obsolete read. Inspect `lastError`; await explicit operations and handle their errors when success matters. Utility status uses its initial snapshot during SSR/hydration; construct server and client handles with matching initial status.

Reorganize React hooks and internal types by ownership, split computed tests by layer, and name regression suites by behavior. This structural cleanup preserves runtime behavior and package entry points.
47 changes: 40 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@

Visit the **[Documentation Website](https://codebelt.github.io/classy-store/)** for tutorials, API reference, and examples.

## Upgrading to 0.7.0
## Upgrading to 0.8.0

React subscriptions now notify synchronously. Remove the React hooks' `sync` option and any `UseClassyStoreOptions` imports. Auto-tracked hooks now require native `WeakRef` support (ES2021). Selector equality options remain available, and ordinary core subscriptions still batch by default.
Replace persistence `unsubscribe()` with `dispose()`, handle rejected promises from explicit persistence operations, and import hydration preparation from `@codebelt/classy-store/react/server`. The public `getVersion` export has been removed. New `useHistoryStatus` and `usePersistStatus` hooks keep utility metadata reactive in React.

See the [0.7.0 upgrade guide and release notes](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7) for before/after examples, SSR and Next.js setup, and persistence changes.
See the [0.8.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_8) for required changes and examples. If upgrading from 0.6.x or earlier, also read the [0.7.0 upgrade guide](https://codebelt.github.io/classy-store/docs/UPGRADING_0_7).

## 🚀 Features

Expand All @@ -32,7 +32,12 @@ See the [0.7.0 upgrade guide and release notes](https://codebelt.github.io/class

## ⚡ Quick Example

```ts
This example creates a shared store for a client-rendered React app. For SSR, create one store per request or provider as shown in the [scoping guide](https://codebelt.github.io/classy-store/docs/REACT_SCOPING).

```tsx
import {createClassyStore} from '@codebelt/classy-store';
import {useClassyStore} from '@codebelt/classy-store/react';

// 1. Plain class — fields are state, getters are computed values, the class is the type
class CartStore {
items: { name: string; price: number; qty: number }[] = [];
Expand Down Expand Up @@ -61,6 +66,30 @@ function AddButton() {
}
```

## Choosing an API

| What you need | API |
| --- | --- |
| Create shared state from a class or object | `createClassyStore(instance)` |
| Render store data in React | `useClassyStore(store)` or `useClassyStore(store, selector)` |
| Create a store owned by a component | `useLocalStore(factory)` |
| Bind a reusable hook to an existing store | `createStoreHook(store)` |
| Render undo/redo availability or pause state | `useHistoryStatus(history)` |
| Render persistence hydration, expiry, or errors | `usePersistStatus(handle)` |
| Prepare matching initial data for SSR/hydration | `prepareStoreForHydration(store)` |
| Read state outside React or observe mutations | `snapshot(store)`, `subscribe(store, listener)`, `subscribeKey(store, key, listener)` |
| Persist state, record history, or inspect it in DevTools | `persist(store, options)`, `withHistory(store)`, `devtools(store)` |
| Track changes inside Map-like or Set-like collections | `reactiveMap()`, `reactiveSet()` |
| Compare selector results one level deep | `shallowEqual(previous, next)` |

`useClassyStore` is the starting point for React components. The two status hooks
are optional: use them only when displaying metadata from an existing history or
persistence handle. They do not create those handles or subscribe to store data.

See the [API reference and import paths](https://codebelt.github.io/classy-store/docs/#package-entry-points) for signatures, public types, and lifecycle details.

## React integration

React subscriptions notify synchronously, so controlled inputs preserve caret position without extra options:

```tsx
Expand All @@ -76,7 +105,7 @@ return (

Ordinary `subscribe(store, listener)` callbacks still batch in a microtask. React batches renders within event handlers, while selectors can run after each mutation.

For server-rendered React apps, call `prepareStoreForHydration(store)` from `@codebelt/classy-store/react` on matching server/client stores before persistence starts. Delayed Suspense boundaries then hydrate from the original data and catch up to live state. See the [SSR guide](https://codebelt.github.io/classy-store/docs/REACT_SSR).
For server-rendered React apps, call `prepareStoreForHydration(store)` from `@codebelt/classy-store/react/server` on matching server/client stores before persistence starts. Delayed Suspense boundaries then hydrate from the original data and catch up to live state. See the [SSR guide](https://codebelt.github.io/classy-store/docs/REACT_SSR).

For Next.js App Router, construct stores inside Client Providers from serializable initial data. Use `@codebelt/classy-store/react/server` when preparing a store in server-only code. See the [App Router guide](https://codebelt.github.io/classy-store/docs/REACT_NEXT) for package boundaries, streaming, and navigation.

Expand All @@ -86,6 +115,8 @@ Auto-tracked React subscriptions require native `WeakRef` support (ES2021) so tr

For independent forms, nested editors, and request-scoped state, see the [typed context example](https://codebelt.github.io/classy-store/docs/REACT_SCOPING). Providers own their store instances and effects; React keys control intentional resets.

Most components only need `useClassyStore(store)` to render application data. Use [`useHistoryStatus(history)`](https://codebelt.github.io/classy-store/docs/#usehistorystatushistory) for undo/redo controls and recording status, or [`usePersistStatus(handle)`](https://codebelt.github.io/classy-store/docs/#usepersiststatushandle) for hydration, expiry, and persistence errors. Both hooks come from `@codebelt/classy-store/react` and update even when store data is unchanged. Manual `save()`, `clear()`, and `rehydrate()` calls reject on failure after reporting through status and `onError`. Handle these promises in event handlers; automatic persistence reports failures without producing unhandled rejections.

## 📦 Installation

```bash
Expand All @@ -94,6 +125,8 @@ npm install @codebelt/classy-store
bun add @codebelt/classy-store
```

The React adapter requires React 18 or later. Install `react` and `react-dom` in your application if it does not already provide them.

## 🤖 AI / LLM Usage

This library provides machine-readable documentation for LLM-powered tools:
Expand All @@ -103,14 +136,12 @@ This library provides machine-readable documentation for LLM-powered tools:

These files follow the [llms.txt standard](https://llmstxt.org/).


## 💡 Why Another State Library

- A class-based store where the class is the object and the type — no separate interface or type definition needed.
- Getters as computed values, cached automatically and only recalculated when something changes.
- No component wrapping — no observers, no HOCs, just a hook.


## 🙏 Acknowledgements

This library wouldn't exist without the ideas pioneered by these projects. Each one taught us something different, and we took the best of each:
Expand All @@ -127,6 +158,8 @@ This library wouldn't exist without the ideas pioneered by these projects. Each

**[Claude 4.6 Opus](https://anthropic.com)** — Let's be real: this library was designed, architected, implemented, tested, and documented almost entirely by Claude 4.6 Opus (Anthropic) via [Cursor](https://cursor.com). From the three-layer proxy architecture to the memoized computed getters with dependency tracking, the cross-snapshot caching strategy, and tests — it was all pair-programmed with an AI that never gets tired of writing Proxy traps. The human brought the vision, the taste, and the "no, make it better" energy. Claude brought the code.

**[OpenAI Codex](https://openai.com/codex/)** — Contributed public API refinements, core and React regression tests, and fixes for hydration, React Compiler compatibility, Next.js integration, performance, memory retention, and persistence. Also helped review and reorganize the codebase, update examples, and improve the documentation.

## 🤝 Contributing

See [CONTRIBUTING.md](./CONTRIBUTING.md) for details on how to contribute and the release workflow.
Expand Down
76 changes: 37 additions & 39 deletions examples/nextjs/src/components/dashboard/StoreInspector.tsx
Original file line number Diff line number Diff line change
@@ -1,56 +1,51 @@
'use client';

import {getVersion, snapshot, subscribe} from '@codebelt/classy-store';
import {useCallback, useEffect, useState} from 'react';
import {snapshot, subscribe} from '@codebelt/classy-store';
import {useEffect, useState} from 'react';
import {ApiInfo} from '@/components/shared/ApiInfo';
import {plannerStore} from '@/stores/planner-store';
import {recipeStore} from '@/stores/recipe-store';
import {shoppingStore} from '@/stores/shopping-store';

export function StoreInspector() {
const [mutationCount, setMutationCount] = useState(0);
const [lastMutated, setLastMutated] = useState<string | null>(null);

const [versions, setVersions] = useState({
const [updateCounts, setUpdateCounts] = useState({
recipe: 0,
shopping: 0,
planner: 0,
});

const updateVersions = useCallback(() => {
setVersions({
recipe: getVersion(recipeStore),
shopping: getVersion(shoppingStore),
planner: getVersion(plannerStore),
});
}, []);

useEffect(() => {
updateVersions();
const unsubs = [
let highlightTimer: ReturnType<typeof setTimeout> | undefined;
function recordUpdate(store: keyof typeof updateCounts, label: string) {
setUpdateCounts((previousCounts) => ({
...previousCounts,
[store]: previousCounts[store] + 1,
}));
setLastMutated(label);
clearTimeout(highlightTimer);
highlightTimer = setTimeout(() => setLastMutated(null), 1000);
}
const subscriptions = [
subscribe(recipeStore, () => {
setMutationCount((c) => c + 1);
setLastMutated('Recipe Store');
updateVersions();
setTimeout(() => setLastMutated(null), 1000);
recordUpdate('recipe', 'Recipe Store');
}),
subscribe(shoppingStore, () => {
setMutationCount((c) => c + 1);
setLastMutated('Shopping Store');
updateVersions();
setTimeout(() => setLastMutated(null), 1000);
recordUpdate('shopping', 'Shopping Store');
}),
subscribe(plannerStore, () => {
setMutationCount((c) => c + 1);
setLastMutated('Planner Store');
updateVersions();
setTimeout(() => setLastMutated(null), 1000);
recordUpdate('planner', 'Planner Store');
}),
];
return () => {
for (const u of unsubs) u();
for (const unsubscribe of subscriptions) unsubscribe();
clearTimeout(highlightTimer);
};
}, [updateVersions]);
}, []);

const totalUpdates =
updateCounts.recipe + updateCounts.shopping + updateCounts.planner;

const recipeSnap = snapshot(recipeStore);
const recipeKeys = Object.keys(recipeSnap);
Expand Down Expand Up @@ -84,11 +79,10 @@ export function StoreInspector() {

<ApiInfo
minimal={true}
apis={['getVersion', 'snapshot', 'subscribe']}
description="Manually subscribes to all stores, tracks mutation count, and displays version numbers and snapshot keys."
apis={['snapshot', 'subscribe']}
description="Subscribes to each store, counts batched updates observed since mounting, and displays snapshot keys."
code={`subscribe(recipeStore, () => {
setMutationCount((c) => c + 1);
updateVersions();
setUpdateCount((previousCount) => previousCount + 1);
});`}
/>
</div>
Expand All @@ -98,10 +92,10 @@ export function StoreInspector() {
<div className="flex items-center gap-4">
<div className="flex-1 bg-black/20 rounded-lg p-3 border border-white/5 relative overflow-hidden">
<p className="text-[10px] text-muted-foreground font-mono mb-1">
TOTAL MUTATIONS
OBSERVED UPDATES
</p>
<p className="text-2xl font-bold font-mono tracking-tight">
{mutationCount}
{totalUpdates}
</p>
{lastMutated && (
<div className="absolute top-0 right-0 h-full w-1 bg-indigo-500 animate-pulse box-shadow-[0_0_10px_theme(colors.indigo.500)]" />
Expand All @@ -110,27 +104,31 @@ export function StoreInspector() {

<div className="flex-1 space-y-2">
{[
{label: 'Recipe', ver: versions.recipe, color: 'text-blue-400'},
{
label: 'Recipe',
count: updateCounts.recipe,
color: 'text-blue-400',
},
{
label: 'Shopping',
ver: versions.shopping,
count: updateCounts.shopping,
color: 'text-emerald-400',
},
{
label: 'Planner',
ver: versions.planner,
count: updateCounts.planner,
color: 'text-orange-400',
},
].map((state) => (
<div
key={state.label}
className="flex justify-between items-center text-xs"
>
<span className="text-muted-foreground">{state.label} v:</span>
<span className="text-muted-foreground">{state.label}:</span>
<span
className={`font-mono font-medium ${state.color} bg-white/5 px-2 py-0.5 rounded`}
>
{state.ver}
{state.count}
</span>
</div>
))}
Expand Down
22 changes: 7 additions & 15 deletions examples/nextjs/src/components/editor/HistoryControls.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
'use client';

import {useClassyStore} from '@codebelt/classy-store/react';
import {useHistoryStatus} from '@codebelt/classy-store/react';
import type {HistoryHandle} from '@codebelt/classy-store/utils';
import {ApiInfo} from '@/components/shared/ApiInfo';

export function HistoryControls({
history,
store,
}: {
history: HistoryHandle;
store: object;
}) {
// Subscribe to store changes so we re-render when canUndo/canRedo changes.
useClassyStore(store);
export function HistoryControls({history}: {history: HistoryHandle}) {
const status = useHistoryStatus(history);

return (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
Expand All @@ -21,15 +14,15 @@ export function HistoryControls({
<button
type="button"
onClick={() => history.undo()}
disabled={!history.canUndo}
disabled={!status.canUndo}
className="px-3 py-1 bg-accent text-white rounded text-sm disabled:opacity-40"
>
Undo
</button>
<button
type="button"
onClick={() => history.redo()}
disabled={!history.canRedo}
disabled={!status.canRedo}
className="px-3 py-1 bg-accent text-white rounded text-sm disabled:opacity-40"
>
Redo
Expand All @@ -50,8 +43,7 @@ export function HistoryControls({
Resume
</button>
<p className="text-xs text-muted ml-auto">
canUndo: {String(history.canUndo)} | canRedo:{' '}
{String(history.canRedo)}
canUndo: {String(status.canUndo)} | canRedo: {String(status.canRedo)}
</p>
</div>
<ApiInfo
Expand All @@ -64,7 +56,7 @@ export function HistoryControls({
'canUndo',
'canRedo',
]}
description="Full history API: undo/redo navigation, pause to batch edits, resume to re-enable tracking."
description="History status updates independently of store data. Pause stops recording; resume enables it for future edits."
code={`history.undo(); history.redo();
history.pause(); history.resume();
history.canUndo; history.canRedo;`}
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/src/components/editor/RecipeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ if (!historyRef.current) {
historyRef.current = withHistory(store, { limit: 30 });
}`}
/>
<HistoryControls history={historyRef.current} store={store} />
<HistoryControls history={historyRef.current} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
Expand Down
9 changes: 0 additions & 9 deletions examples/nextjs/src/components/features/FeatureMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,6 @@ const sections: FeatureSection[] = [
{label: 'Settings / StorageDebugPanel', href: '/settings'},
],
},
{
api: 'getVersion()',
description:
'Returns the monotonically increasing version number of a store',
links: [
{label: 'Dashboard / StoreInspector', href: '/'},
{label: 'Shopping / SnapshotViewer', href: '/shopping'},
],
},
{
api: 'shallowEqual',
description:
Expand Down
Loading
Loading