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
24 changes: 24 additions & 0 deletions .changeset/shallow-selector-breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@ilokesto/state": major
---

### Breaking change: React adapter selector comparison switched from deep to shallow

The React adapter's `useStoreState` now compares selector results with a 1-level shallow comparison (`shallow`, zustand-style) instead of recursive deep comparison (`deepCompare`).

#### What changed
- Added `src/core/shared/shallow.ts`: zustand-style shallow comparison covering `Object.is`, `Map` (entries), `Set` (iterator), arrays, plain objects, and `Date` (`getTime()`). Circular-reference safe by design (1-level only).
- `src/core/React/createUseState.ts`: `deepCompare` removed; `shallow` is now baked into `useStoreState` and always applied. `getSnapshot` and `getServerSnapshot` use separate `createShallowSelector` instances so the cached `prev` snapshot is correctly invalidated when the store or selector identity changes (fixes stale-closure issues during SSR hydration).
- Removed `src/core/shared/deepCompare.ts`.

#### Why
- Deep comparison ran on every render and recursively traversed the whole state; shallow checks one level only.
- The previous `deepCompare` mishandled `Map`, `Set`, and `Date`, and could stack-overflow on circular references.
- Aligns with zustand v5's standard shallow-compare pattern.

#### Migration
- `useStore(s => s.count)` and `useStore(s => ({ a: s.a, b: s.b }))` keep working — the latter now compares first-level values instead of recursing.
- Selectors that return **nested objects** are now compared by reference (`Object.is`). If you need stable equality for a derived nested object, memoize the selector result with `useMemo`, or return a primitive (e.g. `useStore(s => s.date.getTime())`).
- Inline selectors re-create identity every render, which resets `createShallowSelector`'s cache and defeats the optimization. Define selectors at module scope or wrap them in `useCallback`.

Test coverage: `test/shallow.test.ts` (21 cases) covers primitives, objects, arrays, `Map`, `Set`, `Date`, prototype guards, and circular-reference safety.
61 changes: 61 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,67 @@ counterStore.getState().count;
- `@ilokesto/state/middleware` → 미들웨어 헬퍼
- `@ilokesto/state/utils` → `adaptor`, `pipe`, `definePipeableMiddleware`, pipe 타입

## 마이그레이션: deep compare → shallow (v2.0.0)

### 변경 사항

React 어댑터는 이전에 **깊은 비교** (`deepCompare`)를 사용하여 selector 결과가 리렌더를 트리거해야 하는지 판단했습니다. v2.0.0부터는 **shallow 비교**를 사용합니다 — zustand가 사용하는 패턴과 동일합니다. 이는 breaking change입니다.

### 이유

- **성능**: 깊은 비교는 매 렌더마다 실행되어 전체 state를 재귀적으로 순회했습니다. shallow 비교는 1단계만 확인합니다.
- **정확성**: `deepCompare`는 `Map`, `Set`, `Date`를 올바르게 처리하지 못했고, 순환 참조 시 스택 오버플로우가 발생했습니다. shallow 비교는 `Map`, `Set`, 배열, 일반 객체를 올바르게 처리하며 순환 참조에 안전합니다.
- **생태계 정합**: zustand v5가 shallow 비교를 표준 패턴으로 사용합니다.

### 사용자에게 미치는 영향

| 패턴 | 이전 (deep) | 이후 (shallow) |
|---|---|---|
| `useStore(s => s.count)` | 동작 | 동작 (동일) |
| `useStore(s => ({ a: s.a, b: s.b }))` | 깊은 비교 (값이 같으면 항상 같음) | shallow 비교 (1단계 값이 같으면 같음) |
| selector 결과의 중첩 객체 | 깊은 비교 | 참조 비교 (`Object.is`) |
| state의 `Map` / `Set` | 잘못된 비교 | 올바른 shallow 비교 |
| state의 `Date` | 잘못된 비교 | `getTime()` 기반 올바른 shallow 비교 |

### 깊은 비교가 필요한 경우

`useMemo`로 selector 결과를 메모이제이션하세요:

```ts
const value = useMemo(() => {
return computeDerivedState(store.getState());
}, [dependency]);
```

또는 selector에서 원시값을 반환하여 `Object.is`로 충분하게 만드세요:

```ts
const time = useStore(s => s.date.getTime());
```

### selector 참조를 안정적으로 유지하기

shallow selector 캐시는 selector 함수의 참조 동일성을 기준으로 동작합니다. 인라인 selector
(`useStore(s => ({ a: s.a, b: s.b }))`)는 매 렌더마다 새로운 함수 참조를 만들어 캐시를
초기화하고 shallow 최적화를 무의미하게 만듭니다. 다음 중 하나를 선호하세요:

```ts
// 1. 모듈 스코프 selector (순수 파생은 권장)
const selectSlice = (s: State) => ({ a: s.a, b: s.b });
const slice = useStore(selectSlice);

// 2. selector가 props나 다른 반응성 입력에 의존할 때는 useCallback
const selectFiltered = useCallback(
(s: State) => s.items.filter(i => i.id === activeId),
[activeId],
);
const filtered = useStore(selectFiltered);
```

인라인 selector에서 새 객체/배열 리터럴을 반환하면 호출마다 새 참조가 생깁니다. shallow 비교로
1단계 값이 같을 때 리렌더는 막을 수 있지만, selector 참조를 안정화하면 `useSyncExternalStore`가
비교 자체를 건너뛸 수 있습니다.

## 개발

```bash
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,67 @@ This is a breaking change. Callable and variadic pipe syntax has been removed. R
- `@ilokesto/state/middleware` → middleware helpers
- `@ilokesto/state/utils` → `adaptor`, `pipe`, `definePipeableMiddleware`, and pipe types

## Migration: deep compare → shallow (v2.0.0)

### What changed

The React adapter previously used **deep comparison** (`deepCompare`) to determine whether a selector result should trigger a re-render. Starting from v2.0.0, it uses **shallow comparison** instead — matching the pattern used by zustand. This is a breaking change.

### Why

- **Performance**: deep comparison ran on every render, recursively traversing the entire state. Shallow comparison checks one level only.
- **Correctness**: `deepCompare` could not handle `Map`, `Set`, or `Date` correctly, and would stack-overflow on circular references. Shallow comparison handles `Map`, `Set`, arrays, and plain objects correctly, and is inherently safe against circular references.
- **Ecosystem alignment**: zustand v5 uses shallow comparison as the standard pattern.

### What this means for you

| Pattern | Before (deep) | After (shallow) |
|---|---|---|
| `useStore(s => s.count)` | Works | Works (same) |
| `useStore(s => ({ a: s.a, b: s.b }))` | Deep-compared (always equal if values match) | Shallow-compared (equal if 1st-level values match) |
| Nested object in selector result | Deep-compared | Reference-compared (`Object.is`) |
| `Map` / `Set` in state | Incorrect comparison | Correct shallow comparison |
| `Date` in state | Incorrect comparison | Correct shallow comparison via `getTime()` |

### If you need deep comparison

Use `useMemo` to memoize your selector result:

```ts
const value = useMemo(() => {
return computeDerivedState(store.getState());
}, [dependency]);
```

Or return a primitive from your selector so `Object.is` is sufficient:

```ts
const time = useStore(s => s.date.getTime());
```

### Keep selector identity stable

The shallow selector cache is keyed on the selector function identity. An inline selector
(e.g. `useStore(s => ({ a: s.a, b: s.b }))`) creates a new function reference on every render,
which resets the cache and defeats the shallow optimization. Prefer one of:

```ts
// 1. Module-scope selector (preferred for pure derivations)
const selectSlice = (s: State) => ({ a: s.a, b: s.b });
const slice = useStore(selectSlice);

// 2. useCallback when the selector depends on props or other reactive inputs
const selectFiltered = useCallback(
(s: State) => s.items.filter(i => i.id === activeId),
[activeId],
);
const filtered = useStore(selectFiltered);
```

Returning a new object/array literal inside an inline selector also produces a new reference
each call; the shallow comparison still avoids a re-render when first-level values match, but
stabilizing the selector identity lets `useSyncExternalStore` skip the comparison entirely.

## Development

```bash
Expand Down
57 changes: 27 additions & 30 deletions src/core/React/createUseState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,47 @@ import { useMemo, useSyncExternalStore } from 'react';

import { dispatchStoreAction } from '../../lib/actionMetadata.js';
import type { ReducerAction } from '../../types/ReduceFn.js';
import { deepCompare } from '../shared/deepCompare.js';
import { shallow } from '../shared/shallow.js';
import type { UseReducer, UseState } from './types.js';

type Selector<T, S> = (state: T) => S;

const identity = <Value>(value: Value): Value => value;

function createShallowSelector<T, S>(
selector: (state: T) => S,
): (state: T) => S {
let prev: S | undefined;
let hasPrev = false;

return (state: T): S => {
const next = selector(state);

if (hasPrev && shallow(prev as S, next)) {
return prev as S;
}

hasPrev = true;
prev = next;
return next;
};
}

export function useStoreState<T, S, Writer>(
store: Store<T>,
selector: (state: T) => S,
write: Writer,
) {
const subscribe = useMemo(() => store.subscribe.bind(store), [store]);

const { getSnapshot, getServerSnapshot } = useMemo(() => {
let hasMemo = false;
let mStore: T | undefined;
let mSelection: S | undefined;

const mSelector = (nStore: T): S => {
if (!hasMemo) {
hasMemo = true;
mStore = nStore;
const nSelection = selector(nStore);
mSelection = nSelection;
return nSelection;
}

const pStore = mStore as T;
const pSelection = mSelection as S;

if (deepCompare(pStore, nStore)) return pSelection;

const nSelection = selector(nStore);

mStore = nStore;
mSelection = nSelection;
return nSelection;
};
const getSnapshot = useMemo(() => {
const shallowSelector = createShallowSelector(selector);
return () => shallowSelector(store.getState());
}, [store, selector]);

return {
getSnapshot: () => mSelector(store.getState()),
getServerSnapshot: () => mSelector(store.getInitialState()),
};
const getServerSnapshot = useMemo(() => {
const shallowSelector = createShallowSelector(selector);
return () => shallowSelector(store.getInitialState());
}, [store, selector]);

const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
Expand Down
46 changes: 0 additions & 46 deletions src/core/shared/deepCompare.ts

This file was deleted.

83 changes: 83 additions & 0 deletions src/core/shared/shallow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const isIterable = (obj: object): obj is Iterable<unknown> =>
Symbol.iterator in obj;

const hasIterableEntries = (
value: Iterable<unknown>,
): value is Iterable<unknown> & {
entries(): Iterable<[unknown, unknown]>;
} => 'entries' in value;

const compareEntries = (
valueA: { entries(): Iterable<[unknown, unknown]> },
valueB: { entries(): Iterable<[unknown, unknown]> },
): boolean => {
const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries() as Iterable<[unknown, unknown]>);
const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries() as Iterable<[unknown, unknown]>);

if (mapA.size !== mapB.size) {
return false;
}

for (const [key, value] of mapA) {
if (!mapB.has(key) || !Object.is(value, mapB.get(key))) {
return false;
}
}

return true;
};

const compareIterables = (
valueA: Iterable<unknown>,
valueB: Iterable<unknown>,
): boolean => {
const iteratorA = valueA[Symbol.iterator]();
const iteratorB = valueB[Symbol.iterator]();
let nextA = iteratorA.next();
let nextB = iteratorB.next();

while (!nextA.done && !nextB.done) {
if (!Object.is(nextA.value, nextB.value)) {
return false;
}
nextA = iteratorA.next();
nextB = iteratorB.next();
}

return !!nextA.done && !!nextB.done;
};

export function shallow<T>(valueA: T, valueB: T): boolean {
if (Object.is(valueA, valueB)) {
return true;
}

if (
typeof valueA !== 'object' ||
valueA === null ||
typeof valueB !== 'object' ||
valueB === null
) {
return false;
}

if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) {
return false;
}

if (valueA instanceof Date && valueB instanceof Date) {
return valueA.getTime() === valueB.getTime();
}

if (isIterable(valueA) && isIterable(valueB)) {
if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) {
return compareEntries(valueA, valueB);
}
return compareIterables(valueA, valueB);
}

return compareEntries(
{ entries: () => Object.entries(valueA) },
{ entries: () => Object.entries(valueB) },
);
}
Loading
Loading