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/warm-icons-scream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codebelt/classy-store": minor
---

Add sync option
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ function AddButton() {
}
```

For React controlled inputs, opt the field subscription into synchronous timing so React can preserve caret position and IME composition:

```tsx
const name = useClassyStore(formStore, (state) => state.name, {sync: true});

return (
<input
value={name}
onChange={(event) => formStore.setName(event.target.value)}
/>
);
```

## 📦 Installation

```bash
Expand Down
28 changes: 14 additions & 14 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
},
"dependencies": {
"@codebelt/classy-store": "workspace:*",
"next": "16.1.6",
"next": "16.2.9",
"react": "19.2.3",
"react-dom": "19.2.3"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function DashboardOverview() {
count: state.recipeCount,
avgTime: state.averageTotalTime,
}),
shallowEqual,
{isEqual: shallowEqual},
);

const shoppingStats = useClassyStore(
Expand All @@ -77,7 +77,7 @@ export function DashboardOverview() {
total: state.totalItems,
unchecked: state.uncheckedCount,
}),
shallowEqual,
{isEqual: shallowEqual},
);

const plannerStats = useClassyStore(
Expand All @@ -86,7 +86,7 @@ export function DashboardOverview() {
meals: state.totalMealsPlanned,
pct: state.completionPercentage,
}),
shallowEqual,
{isEqual: shallowEqual},
);

const cards = [
Expand Down Expand Up @@ -127,7 +127,7 @@ export function DashboardOverview() {
code={`const stats = useClassyStore(recipeStore, (state) => ({
count: state.recipeCount,
avgTime: state.averageTotalTime,
}), shallowEqual);`}
}), {isEqual: shallowEqual});`}
/>
</div>

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 @@ -17,7 +17,7 @@ export function RecipeEditor() {
if (!historyRef.current) {
historyRef.current = withHistory(store, {limit: 30});
}
const snap = useClassyStore(store);
const snap = useClassyStore(store, {sync: true});

return (
<div className="space-y-4">
Expand Down
2 changes: 1 addition & 1 deletion examples/nextjs/src/components/recipes/RecipeCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function RecipeCard({recipeId}: {recipeId: string}) {
ingredientCount: r.ingredients.length,
};
},
shallowEqual,
{isEqual: shallowEqual},
);

if (!recipe) return null;
Expand Down
4 changes: 2 additions & 2 deletions examples/nextjs/src/components/shopping/ShoppingStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function ShoppingStats() {
checked: state.checkedCount,
lastAction: state.lastAction,
}),
shallowEqual,
{isEqual: shallowEqual},
);

return (
Expand All @@ -27,7 +27,7 @@ export function ShoppingStats() {
unchecked: state.uncheckedCount,
checked: state.checkedCount,
lastAction: state.lastAction,
}), shallowEqual);`}
}), {isEqual: shallowEqual});`}
/>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-card border border-border rounded-lg p-4">
Expand Down
50 changes: 49 additions & 1 deletion examples/react/src/demos/reactivity/ReactiveFundamentalsDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ import {CounterStore, counterStore} from '../../stores/counterStore';

const batchStore = createClassyStore(new CounterStore());

class DemoFormStore {
name = 'Alice';

setName(value: string) {
this.name = value;
}

reset() {
this.name = 'Alice';
}
}

const formStore = createClassyStore(new DemoFormStore());

const names = ['World', 'Bun', 'React', 'Store'];
let nameIndex = 0;

Expand Down Expand Up @@ -44,6 +58,17 @@ function BatchCard() {
// 1000 mutations → 1 render (microtask batching)
const count = useClassyStore(batchStore, (state) => state.count);
return <div>{count}</div>;
}

function NameInput() {
const name = useClassyStore(formStore, (state) => state.name, { sync: true });

return (
<input
value={name}
onChange={(event) => formStore.setName(event.target.value)}
/>
);
}`;

function CountCard() {
Expand Down Expand Up @@ -100,13 +125,34 @@ function BatchCard() {
);
}

function NameInputCard() {
const name = useClassyStore(formStore, (state) => state.name, {sync: true});
const renders = useRenderCount();

return (
<div className="flex-1 flex items-center justify-between gap-3 bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-3 py-2.5">
<label className="flex-1 min-w-0">
<span className="block text-[10px] text-zinc-400 uppercase tracking-wide mb-1">
Name Input
</span>
<input
value={name}
onChange={(event) => formStore.setName(event.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-sm text-zinc-100 outline-none focus:border-emerald-400"
/>
</label>
<RenderBadge count={renders} />
</div>
);
}

export function ReactiveFundamentalsDemo() {
const [lastBatch, setLastBatch] = useState(false);

return (
<DemoContainer
title="Reactive Fundamentals"
description="Render isolation via selectors + microtask batching + mutable deep updates — all in one store."
description="Render isolation via selectors + controlled inputs + microtask batching + mutable deep updates — all in one store."
codeTabs={[
{label: 'Store', code: STORE_CODE, language: 'typescript'},
{label: 'Component', code: COMPONENT_CODE},
Expand All @@ -115,6 +161,7 @@ export function ReactiveFundamentalsDemo() {
<div className="flex flex-col gap-3 mb-4">
<CountCard />
<NameCard />
<NameInputCard />
<BatchCard />
</div>

Expand Down Expand Up @@ -144,6 +191,7 @@ export function ReactiveFundamentalsDemo() {
onClick={() => {
counterStore.reset();
batchStore.reset();
formStore.reset();
setLastBatch(false);
}}
>
Expand Down
4 changes: 2 additions & 2 deletions examples/react/src/demos/shallow-equal/ShallowEqualDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const name = useClassyStore(profileStore, (state) => ({
const name = useClassyStore(
profileStore,
(state) => ({ first: state.firstName, last: state.lastName }),
shallowEqual
{ isEqual: shallowEqual }
);`;

const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'];
Expand Down Expand Up @@ -82,7 +82,7 @@ function NameCardWith() {
const name = useClassyStore(
profileStore,
(state) => ({first: state.firstName, last: state.lastName}),
shallowEqual,
{isEqual: shallowEqual},
);
const renders = useRenderCount();

Expand Down
63 changes: 63 additions & 0 deletions packages/classy-store/src/core/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,51 @@ describe('createClassyStore() — core reactivity', () => {
expect(listener2).toHaveBeenCalledTimes(1);
});

it('sync listeners fire immediately without waiting for the batch flush', async () => {
const s = createClassyStore({count: 0});
const listener = mock(() => {});
subscribe(s, listener, {sync: true});

s.count = 1;

expect(listener).toHaveBeenCalledTimes(1);
await flush();
expect(listener).toHaveBeenCalledTimes(1);
});

it('sync listeners fire per mutation while batched listeners stay deduped', async () => {
const s = createClassyStore({a: 0, b: 0});
const syncListener = mock(() => {});
const batchedListener = mock(() => {});
subscribe(s, syncListener, {sync: true});
subscribe(s, batchedListener);

s.a = 1;
s.b = 2;

expect(syncListener).toHaveBeenCalledTimes(2);
expect(batchedListener).toHaveBeenCalledTimes(0);

await flush();

expect(syncListener).toHaveBeenCalledTimes(2);
expect(batchedListener).toHaveBeenCalledTimes(1);
});

it('unsubscribe stops sync notifications', async () => {
const s = createClassyStore({count: 0});
const listener = mock(() => {});
const unsub = subscribe(s, listener, {sync: true});

s.count = 1;
expect(listener).toHaveBeenCalledTimes(1);

unsub();
s.count = 2;
await flush();
expect(listener).toHaveBeenCalledTimes(1);
});

it('subscribe on a child proxy fires when the child mutates', async () => {
const s = createClassyStore({user: {name: 'Alice'}});
const listener = mock(() => {});
Expand Down Expand Up @@ -746,6 +791,24 @@ describe('createClassyStore() — core reactivity', () => {

expect(secondListener).toHaveBeenCalledTimes(1);
});

it('calls remaining sync listeners even if an earlier one throws', () => {
const s = createClassyStore({count: 0});
const secondListener = mock(() => {});

subscribe(
s,
() => {
throw new Error('boom');
},
{sync: true},
);
subscribe(s, secondListener, {sync: true});

s.count = 1;

expect(secondListener).toHaveBeenCalledTimes(1);
});
});

describe('bound method cache invalidation', () => {
Expand Down
Loading