Skip to content

Commit af4d62a

Browse files
committed
docs(changeset): ✨ **Feature:** Introduced immer middleware for Kosha
You can now opt-in to immutable state updates via [Immer](https://github.com/immerjs/immer). This enables writing simpler, more intuitive state updates using mutable-like syntax: ```ts const useStore = create( immer(set => ({ count: 0, increment: () => set(state => { state.count++; }), })), ); ```` 💡 Listeners will still be triggered correctly as Immer tracks mutations internally and returns new state safely. Use with care: avoid returning any value from the mutator function to ensure Immer can do its job properly.
1 parent 1648ac3 commit af4d62a

File tree

11 files changed

+166
-33
lines changed

11 files changed

+166
-33
lines changed

.changeset/moody-poets-agree.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"kosha": minor
3+
---
4+
5+
**Feature:** Introduced `immer` middleware for Kosha
6+
7+
You can now opt-in to immutable state updates via [Immer](https://github.com/immerjs/immer).
8+
9+
This enables writing simpler, more intuitive state updates using mutable-like syntax:
10+
11+
```ts
12+
const useStore = create(
13+
immer(set => ({
14+
count: 0,
15+
increment: () =>
16+
set(state => {
17+
state.count++;
18+
}),
19+
})),
20+
);
21+
```
22+
23+
💡 Listeners will still be triggered correctly as Immer tracks mutations internally and returns new state safely.
24+
25+
Use with care: avoid returning any value from the mutator function to ensure Immer can do its job properly.

lib/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"esbuild-plugin-rdi": "^0.0.0",
5454
"esbuild-plugin-react18": "0.2.6",
5555
"esbuild-plugin-react18-css": "^0.0.4",
56+
"immer": "^10.1.1",
5657
"jsdom": "^26.1.0",
5758
"react": "^19.1.0",
5859
"react-dom": "^19.1.0",

lib/src/index.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,23 @@ export type Immutable<T> = {
1818
: T[K];
1919
};
2020

21+
export type Mutable<T> = {
22+
-readonly [K in keyof T]: T[K] extends object
23+
? T[K] extends Function
24+
? T[K]
25+
: Mutable<T[K]>
26+
: T[K];
27+
};
28+
2129
export type BaseType = Omit<object, "__get">;
2230
type ListenerWithSelector<T, U> = [listener: () => void, selectorFunc?: (state: T) => U];
31+
2332
export type StateSetterArgType<T> = ((newState: Immutable<T>) => T | Partial<T>) | Partial<T> | T;
2433

2534
export type StateSetter<T> = {
26-
_(state: StateSetterArgType<T>, replace?: false): void;
27-
_(state: ((newState: Immutable<T>) => T) | T, replace: true): void;
28-
}["_"];
35+
(state: StateSetterArgType<T>, replace?: false): void;
36+
(state: ((newState: Immutable<T>) => T) | T, replace: true): void;
37+
};
2938

3039
export type StoreCreator<T extends BaseType> = (
3140
set: StateSetter<T>,

lib/src/middleware/immer.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { produce } from "immer";
2+
import { BaseType, Mutable, StateSetter as KoshaStateSetter } from "..";
3+
4+
type StateSetterArgType<T> = ((draft: Mutable<T>) => void | T) | Partial<T> | T;
5+
6+
type StateSetter<T> = {
7+
(state: StateSetterArgType<T>, replace?: false): void;
8+
(state: ((draft: Mutable<T>) => void | T) | T, replace: true): void;
9+
};
10+
11+
type StoreCreator<T extends BaseType> = (
12+
set: StateSetter<T>,
13+
get: () => T | null,
14+
) => T & { __get?: () => T | null };
15+
16+
export const immer =
17+
<T extends BaseType>(storeCreator: StoreCreator<T>) =>
18+
(set: KoshaStateSetter<T>, get: () => T | null) => {
19+
const immerSet: StateSetter<T> = (fnOrState, replace?) => {
20+
const current = get()!;
21+
let nextState = fnOrState instanceof Function ? produce(current, fnOrState) : fnOrState;
22+
23+
if (replace) set(nextState as T, true);
24+
else set(nextState as Partial<T>);
25+
};
26+
27+
return {
28+
...storeCreator(immerSet, get),
29+
};
30+
};

lib/src/middleware/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from "./persist";
2+
export * from "./immer";

packages/shared/src/client/demo/compare/index.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const P1 = ({ p1, setP1 }: Pick<ComponentProps, "p1" | "setP1">) => {
1414
renderCountRef.current++;
1515
return (
1616
<div>
17-
<p className="display">
17+
<p>
1818
p1:
1919
<button onClick={() => setP1(crypto.randomUUID())}>Update</button>
2020
{p1.slice(25)} | renderCount is {renderCountRef.current}
@@ -28,7 +28,7 @@ const P2 = ({ p2, setP2 }: Pick<ComponentProps, "p2" | "setP2">) => {
2828
renderCountRef.current++;
2929
return (
3030
<div>
31-
<p className="display">
31+
<p>
3232
p2:
3333
<button onClick={() => setP2(Math.random())}>Update</button>
3434
{p2.toFixed(6)} | renderCount is {renderCountRef.current}
@@ -79,7 +79,7 @@ export const Compare = () => (
7979
<P2ExtractAsObj type="kosha" />
8080
<hr />
8181
<h4>Extract as Array</h4>
82-
<code>{`const [p2, setP2] = myStore(({ p2, setP2 }) => [p2, setP2] )`}</code>
82+
<code>{`const [p2, setP2] = myStore(({ p2, setP2 }) => [p2, setP2])`}</code>
8383
<P1ExtractAsArray type="kosha" />
8484
<P2ExtractAsArray type="kosha" />
8585
<hr />
@@ -92,19 +92,39 @@ export const Compare = () => (
9292
<h2>Zustand</h2>
9393
<h4>Extract as Object</h4>
9494
<code>{`const { p2, setP2 } = myStore(({ p2, setP2 }) => ({ p2, setP2 }))`}</code>
95-
<ErrorBoundary fallback={<p>⚠️Something went wrong</p>}>
95+
<ErrorBoundary
96+
fallback={
97+
<div>
98+
<p>⚠️Something went wrong</p>
99+
</div>
100+
}>
96101
<P1ExtractAsObj type="zustand" />
97102
</ErrorBoundary>
98-
<ErrorBoundary fallback={<p>⚠️Something went wrong</p>}>
103+
<ErrorBoundary
104+
fallback={
105+
<div>
106+
<p>⚠️Something went wrong</p>
107+
</div>
108+
}>
99109
<P2ExtractAsObj type="zustand" />
100110
</ErrorBoundary>
101111
<hr />
102112
<h4>Extract as Array</h4>
103-
<code>{`const [p2, setP2] = myStore(({ p2, setP2 }) => [p2, setP2] )`}</code>
104-
<ErrorBoundary fallback={<p>⚠️Something went wrong</p>}>
113+
<code>{`const [p2, setP2] = myStore(({ p2, setP2 }) => [p2, setP2])`}</code>
114+
<ErrorBoundary
115+
fallback={
116+
<div>
117+
<p>⚠️Something went wrong</p>
118+
</div>
119+
}>
105120
<P1ExtractAsArray type="zustand" />
106121
</ErrorBoundary>
107-
<ErrorBoundary fallback={<p>⚠️Something went wrong</p>}>
122+
<ErrorBoundary
123+
fallback={
124+
<div>
125+
<p>⚠️Something went wrong</p>
126+
</div>
127+
}>
108128
<P2ExtractAsArray type="zustand" />
109129
</ErrorBoundary>
110130
<hr />

packages/shared/src/client/demo/compare/store.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { create as createKosha } from "kosha";
1+
import { create as createKosha, StoreCreator } from "kosha";
22
import { create as createZustand } from "zustand";
33

44
type SetStateAction<T> = (state: T | ((s: T) => T)) => void;
@@ -27,9 +27,7 @@ const defaultState: ICompareStore = {
2727
a: [],
2828
};
2929

30-
const storeCreator = (
31-
set: SetStateAction<Partial<ICompareStore & ICompareStoreActions>>,
32-
): ICompareStore & ICompareStoreActions => ({
30+
const storeCreator: StoreCreator<ICompareStore & ICompareStoreActions> = set => ({
3331
...defaultState,
3432
setP1: p1 => set({ p1 }),
3533
setP2: p2 => set({ p2 }),

packages/shared/src/client/demo/demo.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import PersistedCounterCode from "./persist?raw";
1717
import { Compare } from "./compare";
1818
import compareCode from "./compare?raw";
1919
import compareStoreCode from "./compare/store?raw";
20+
import { CounterWithImmer } from "./immer";
21+
import CounterWithImmerCode from "./immer?raw";
2022

2123
const compareExCode = [
2224
{ filename: "compare.tsx", code: compareCode },
@@ -61,6 +63,10 @@ export function Demo() {
6163
<PersistedCounter />
6264
<CodeDisplay code={[{ filename: "index.tsx", code: PersistedCounterCode }]} />
6365
</div>
66+
<div className={styles.demo}>
67+
<CounterWithImmer />
68+
<CodeDisplay code={[{ filename: "index.tsx", code: CounterWithImmerCode }]} />
69+
</div>
6470
</>
6571
);
6672
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { create } from "kosha";
2+
import { immer } from "kosha/middleware";
3+
4+
interface CounterStore {
5+
count: number;
6+
setCount: (count: number) => void;
7+
}
8+
9+
const useKoshaWithImmer = create(
10+
immer<CounterStore>(set => ({
11+
count: 0,
12+
setCount: (count: number) =>
13+
set(state => {
14+
state.count = count;
15+
}),
16+
})),
17+
);
18+
19+
export const CounterWithImmer = () => {
20+
const { count, setCount } = useKoshaWithImmer();
21+
return (
22+
<div>
23+
<h2>Example using immer middleware</h2>
24+
<div>Count: {count}</div>
25+
<button onClick={() => setCount(count + 1)}>Increment</button>
26+
</div>
27+
);
28+
};

packages/shared/src/client/demo/persist/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const PersistedCounter = () => {
2323
const { count, localCount, setCount, setLocalCount } = usePersistedKosha();
2424
return (
2525
<div>
26+
<h2>Example using persist middleware</h2>
2627
<div>Count: {count}</div>
2728
<button onClick={() => setCount(count + 1)}>Increment</button>
2829
<div>Local Count: {localCount}</div>

0 commit comments

Comments
 (0)