Skip to content
Open
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
33 changes: 27 additions & 6 deletions content/docs/state/advanced/persist-migrations.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,34 @@ description: "persist의 storage type, versioning, migration caveat입니다."
migration function은 stored version index부터 최신 migration까지 실행됩니다. 결과는 새 version과 함께 다시 저장됩니다.

```ts lineNumbers
persist({ theme: 'light', count: 0 }, {
local: 'settings',
migrate: [
(old) => ({ theme: String(old), count: 0 }),
(old) => ({ ...old, count: Number(old.count ?? 0) }),
],
import { persist } from '@ilokesto/state/middleware';
import type { PersistMigration } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

type SettingsV1 = { theme: string };
type SettingsState = { theme: string; count: number };

const toV1: PersistMigration<unknown, SettingsV1> = (old) => ({
theme: typeof old === 'string' ? old : 'light',
});
const toCurrent: PersistMigration<SettingsV1, SettingsState> = (old) => ({
...old,
count: 0,
});
const decodeSettings = (value: unknown): SettingsState | null => {
if (typeof value !== 'object' || value === null) return null;
if (!('theme' in value) || typeof value.theme !== 'string') return null;
if (!('count' in value) || typeof value.count !== 'number') return null;
return { theme: value.theme, count: value.count };
};

const settingsStore = pipe
.use(persist({
local: 'settings',
migrate: [toV1, toCurrent],
decode: decodeSettings,
}))
.create<SettingsState>({ theme: 'light', count: 0 });
```

## 주의할 점
Expand Down
33 changes: 27 additions & 6 deletions content/docs/state/advanced/persist-migrations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,34 @@ description: "Storage types, versioning, and migration caveats for persist."
Migration functions run from the stored version index until the latest migration. The result is written back with the new version.

```ts lineNumbers
persist({ theme: 'light', count: 0 }, {
local: 'settings',
migrate: [
(old) => ({ theme: String(old), count: 0 }),
(old) => ({ ...old, count: Number(old.count ?? 0) }),
],
import { persist } from '@ilokesto/state/middleware';
import type { PersistMigration } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

type SettingsV1 = { theme: string };
type SettingsState = { theme: string; count: number };

const toV1: PersistMigration<unknown, SettingsV1> = (old) => ({
theme: typeof old === 'string' ? old : 'light',
});
const toCurrent: PersistMigration<SettingsV1, SettingsState> = (old) => ({
...old,
count: 0,
});
const decodeSettings = (value: unknown): SettingsState | null => {
if (typeof value !== 'object' || value === null) return null;
if (!('theme' in value) || typeof value.theme !== 'string') return null;
if (!('count' in value) || typeof value.count !== 'number') return null;
return { theme: value.theme, count: value.count };
};

const settingsStore = pipe
.use(persist({
local: 'settings',
migrate: [toV1, toCurrent],
decode: decodeSettings,
}))
.create<SettingsState>({ theme: 'light', count: 0 });
```

## Caveats
Expand Down
24 changes: 20 additions & 4 deletions content/docs/state/advanced/selector-semantics.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,36 @@ description: "adapter별 selector와 snapshot 동작입니다."

# Selector 동작

selector는 adapter-level projection function입니다. underlying store state를 바꾸지 않고 reactive reader가 받을 값을 결정합니다.
selector는 adapter-level projection function입니다. snapshot을 받고 underlying store state를 바꾸지 않으며 reactive reader가 받을 값을 결정합니다. object state는 `Readonly<T>`이고 callable state는 exact `T`로 유지되어 임의의 generic/overloaded signature와 선언된 own-property modifier를 보존합니다. 전체 state reactive 결과와 lifecycle 밖의 `readOnly()` snapshot도 같은 계약을 사용합니다. plain-state writer는 mutable next-state와 updater 계약을 유지하고 reducer writer는 typed action을 받습니다.

## 공통 shallow 비교

모든 adapter(React, Vue, Solid, Svelte, Angular)는 selector 결과가 notification을 발생시킬지 결정하기 위해 동일한 1-level `shallow` 비교를 사용합니다. zustand v5 패턴과 같습니다.

| 값 타입 | 비교 방식 |
|---|---|
| 원시값 | `Object.is` |
| plain object | shallow — 1st-level key/value를 `Object.is`로 비교 |
| 배열 | shallow — 요소별 `Object.is` 비교 |
| `Map` / `Set` | entries/values를 `Object.is`로 비교 |
| `Date` | `getTime()` 동등성 |
| `RegExp` | `source`와 `flags` 동등성 |
| 기타 빌트인 (Error, Promise, enumerable own property가 없는 class instance) | 참조 동등성 (`Object.is`) |

store가 변경되어도 selected value가 shallow-equal이면 framework consumer에 notify하지 않습니다. 관련 있는 update는 정확히 한 번만 notify합니다.

## React snapshots

React는 `useSyncExternalStore`를 사용합니다. snapshot getter는 selector를 적용하고, 전체 store snapshot이 내부 `deepCompare` helper 기준으로 깊게 같으면 이전 selection을 재사용합니다.
React는 `useSyncExternalStore`를 사용합니다. snapshot getter는 selector를 적용하고 결과가 shallow-equal이면 이전 selection을 재사용합니다. server snapshot은 store의 initial state에서 선택하여 hydration 의미를 보존합니다.

## Vue, Solid, Angular

Vue는 최신 state를 shallow ref에 보관하고 `ComputedRef`를 노출합니다. Solid는 `from`과 `createMemo`로 `Accessor`를 노출합니다. Angular는 signal snapshot을 저장하고 computed `Signal`을 반환합니다.
Vue는 최신 state를 shallow ref에 보관하고 `ComputedRef`를 노출합니다. Solid는 `createSignal`로 `Accessor`를 노출합니다. Angular는 signal snapshot을 저장하고 computed `Signal`을 반환합니다.

## Svelte

Svelte `select`는 subscription update마다 selector를 실행하는 readable store를 만듭니다.

## 주의할 점

selector는 cheap and pure하게 유지하세요. selector가 매번 새 object를 만들면 semantic value가 같아 보여도 일부 adapter에서 notify 또는 recompute가 일어날 수 있습니다.
selector는 cheap and pure하게 유지하세요. selector가 매번 새 object를 만들면 semantic value가 같아 보여도 일부 adapter에서 notify 또는 recompute가 일어날 수 있습니다. selector identity를 안정적으로 유지하려면 module scope에 정의하거나 `useCallback`으로 감싸세요.
24 changes: 20 additions & 4 deletions content/docs/state/advanced/selector-semantics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,36 @@ description: "How selectors and snapshots behave across adapters."

# Selector semantics

Selectors are adapter-level projection functions. They do not change the underlying store state; they decide what a reactive reader receives.
Selectors are adapter-level projection functions. They receive snapshots and do not change the underlying store state; they decide what a reactive reader receives. Object state is `Readonly<T>`, while callable state remains exact `T`, preserving arbitrary generic and overloaded signatures and its declared own-property modifiers. Full-state reactive results and lifecycle-free `readOnly()` snapshots use the same contract. Plain-state writers retain mutable next-state and updater contracts, while reducer writers accept typed actions.

## Shared shallow comparison

All adapters (React, Vue, Solid, Svelte, Angular) use the same one-level `shallow` comparison to decide whether a selector result should trigger a notification. This mirrors the zustand v5 pattern.

| Value type | Comparison |
|---|---|
| Primitives | `Object.is` |
| Plain objects | Shallow — first-level keys/values compared via `Object.is` |
| Arrays | Shallow — element-by-element via `Object.is` |
| `Map` / `Set` | Entries/values compared via `Object.is` |
| `Date` | `getTime()` equality |
| `RegExp` | `source` and `flags` equality |
| Other built-ins (Error, Promise, class instances without enumerable own properties) | Reference equality (`Object.is`) |

An update that changes the store but leaves the selected value shallow-equal does not notify the framework consumer. A relevant update notifies the consumer exactly once.

## React snapshots

React uses `useSyncExternalStore`. Its snapshot getter applies the selector and reuses the previous selection when the full store snapshot is deeply equal according to the internal `deepCompare` helper.
React uses `useSyncExternalStore`. Its snapshot getter applies the selector and reuses the previous selection when the result is shallow-equal. The server snapshot selects from the store's initial state, preserving hydration semantics.

## Vue, Solid, and Angular

Vue stores the latest state in a shallow ref and exposes a `ComputedRef`. Solid uses `from` plus `createMemo` to expose an `Accessor`. Angular stores a signal snapshot and returns a computed `Signal`.
Vue stores the latest state in a shallow ref and exposes a `ComputedRef`. Solid uses `createSignal` to expose an `Accessor`. Angular stores a signal snapshot and returns a computed `Signal`.

## Svelte

Svelte `select` creates a readable store that runs the selector for each subscription update.

## Caveat

Selectors should be cheap and pure. If a selector allocates a new object every time, some adapters may still notify or recompute even when the semantic value feels unchanged.
Selectors should be cheap and pure. If a selector allocates a new object every time, some adapters may still notify or recompute even when the semantic value feels unchanged. Define selectors at module scope or wrap them in `useCallback` to keep their identity stable.
2 changes: 1 addition & 1 deletion content/docs/state/core-concepts.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const useCounter = create(reduce, { count: 0 });

## Selectors

어댑터는 selector를 받아 컴포넌트가 필요한 조각만 받을 수 있게 합니다. React는 `useSyncExternalStore`와 deep compare helper로 snapshot을 memoize합니다.
어댑터는 selector를 받아 컴포넌트가 필요한 조각만 받을 수 있게 합니다. 모든 adapter는 같은 1단계 `shallow` 비교를 사용하고, React는 `useSyncExternalStore`로 snapshot을 memoize합니다.

## Middleware pipeline

Expand Down
2 changes: 1 addition & 1 deletion content/docs/state/core-concepts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const useCounter = create(reduce, { count: 0 });

## Selectors

Adapters accept selectors so a component can receive only the part it needs. React also memoizes snapshots through `useSyncExternalStore` and a deep comparison helper.
Adapters accept selectors so a component can receive only the part it needs. Every adapter uses the same one-level `shallow` comparison; React also memoizes snapshots through `useSyncExternalStore`.

## Middleware pipeline

Expand Down
4 changes: 3 additions & 1 deletion content/docs/state/guides/lifecycle-reads-writes.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ unsubscribe();

`readOnly`는 현재 runtime의 current store value를 읽습니다. React adapter는 `useSyncExternalStore`의 server snapshot으로 store initial state를 사용합니다. Hydration surprise를 피하려면 server와 client에서 같은 initial state로 store를 만들거나, subscribed UI를 render하기 전에 명시적으로 hydrate하세요.

Persisted state는 browser storage가 browser에서만 가능하다는 점을 기억하세요. Storage access가 예상되는 곳에는 persistence middleware를 사용하고, server rendering path는 initial state만으로도 동작하게 유지하세요.
Persisted state에서 `persist`는 서버에서 평가해도 안전합니다 — `window`가 없는 환경에서 storage 읽기는 `null`을 반환하므로 store는 initial state를 유지합니다. 하지만 기본 eager hydration은 클라이언트 store 생성 시점에 영속값을 적용하므로, Next.js App Router 같은 SSR 환경에서 React hydration mismatch가 발생합니다.

`skipHydration: true`를 사용하고 client effect에서 `store.persist.rehydrate()`를 호출해 초기 클라이언트 렌더 이후로 hydration을 지연시키세요. 전체 SSR 패턴은 [persist 미들웨어 문서](/ko/state/middleware/persist)를 참고하세요.

## Testing pattern

Expand Down
6 changes: 4 additions & 2 deletions content/docs/state/guides/lifecycle-reads-writes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,11 @@ If you subscribe manually, you own `unsubscribe`. Do not hide long-lived subscri

## SSR and initial state

`readOnly` reads the current store value in the current runtime. Reacts adapter uses the store initial state as the server snapshot for `useSyncExternalStore`. To avoid hydration surprises, create stores with the same initial state on server and client, or hydrate explicitly before rendering subscribed UI.
`readOnly` reads the current store value in the current runtime. React's adapter uses the store initial state as the server snapshot for `useSyncExternalStore`. To avoid hydration surprises, create stores with the same initial state on server and client, or hydrate explicitly before rendering subscribed UI.

For persisted state, remember that browser storage is only available in the browser. Use persistence middleware where storage access is expected, and keep server rendering paths able to work with the initial state.
For persisted state, `persist` is safe to evaluate on the server — storage reads return `null` when `window` is unavailable, so the store stays at its initial state. However, eager hydration (the default) applies the persisted value at store creation on the client, which causes a React hydration mismatch in SSR frameworks like Next.js App Router.

Use `skipHydration: true` and call `store.persist.rehydrate()` in a client effect to defer hydration until after the initial client render. See the [persist middleware docs](/en/state/middleware/persist) for the full SSR pattern.

## Testing pattern

Expand Down
18 changes: 13 additions & 5 deletions content/docs/state/guides/plain-state.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,19 @@ import { create } from '@ilokesto/state/react';
import { logger, persist } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

const searchStore = pipe(
initialSearchState,
persist({ local: 'search-state' }),
logger({ collapsed: true }),
);
const decodeSearch = (value: unknown): SearchState | null => {
if (typeof value !== 'object' || value === null) return null;
if (!('query' in value) || typeof value.query !== 'string') return null;
if (!('page' in value) || typeof value.page !== 'number') return null;
if (!('pageSize' in value) || typeof value.pageSize !== 'number') return null;
if (!('sort' in value) || (value.sort !== 'relevance' && value.sort !== 'newest')) return null;
return { query: value.query, page: value.page, pageSize: value.pageSize, sort: value.sort };
};

const searchStore = pipe
.use(persist({ local: 'search-state', decode: decodeSearch }))
.use(logger({ collapsed: true }))
.create<SearchState>(initialSearchState);

export const useSearch = create<SearchState>(searchStore);
```
Expand Down
18 changes: 13 additions & 5 deletions content/docs/state/guides/plain-state.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,11 +131,19 @@ import { create } from '@ilokesto/state/react';
import { logger, persist } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

const searchStore = pipe(
initialSearchState,
persist({ local: 'search-state' }),
logger({ collapsed: true }),
);
const decodeSearch = (value: unknown): SearchState | null => {
if (typeof value !== 'object' || value === null) return null;
if (!('query' in value) || typeof value.query !== 'string') return null;
if (!('page' in value) || typeof value.page !== 'number') return null;
if (!('pageSize' in value) || typeof value.pageSize !== 'number') return null;
if (!('sort' in value) || (value.sort !== 'relevance' && value.sort !== 'newest')) return null;
return { query: value.query, page: value.page, pageSize: value.pageSize, sort: value.sort };
};

const searchStore = pipe
.use(persist({ local: 'search-state', decode: decodeSearch }))
.use(logger({ collapsed: true }))
.create<SearchState>(initialSearchState);

export const useSearch = create<SearchState>(searchStore);
```
Expand Down
24 changes: 18 additions & 6 deletions content/docs/state/guides/reducer-state.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,24 @@ import { create } from '@ilokesto/state/react';
import { devtools, logger, persist } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

const cartStore = pipe(
initialCartState,
persist({ local: 'cart' }),
logger({ collapsed: true, diff: true }),
devtools('cart'),
);
const isCartItem = (value: unknown): value is CartItem => {
return typeof value === 'object' && value !== null
&& 'id' in value && typeof value.id === 'string'
&& 'quantity' in value && typeof value.quantity === 'number';
};

const decodeCart = (value: unknown): CartState | null => {
if (typeof value !== 'object' || value === null) return null;
if (!('items' in value) || !Array.isArray(value.items) || !value.items.every(isCartItem)) return null;
if (!('coupon' in value) || (value.coupon !== null && typeof value.coupon !== 'string')) return null;
return { items: value.items, coupon: value.coupon };
};

const cartStore = pipe
.use(persist({ local: 'cart', decode: decodeCart }))
.use(logger({ collapsed: true, diff: true }))
.use(devtools('cart'))
.create<CartState>(initialCartState);

export const useCart = create<CartState, CartAction>(reduceCart, cartStore);
```
Expand Down
24 changes: 18 additions & 6 deletions content/docs/state/guides/reducer-state.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,24 @@ import { create } from '@ilokesto/state/react';
import { devtools, logger, persist } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';

const cartStore = pipe(
initialCartState,
persist({ local: 'cart' }),
logger({ collapsed: true, diff: true }),
devtools('cart'),
);
const isCartItem = (value: unknown): value is CartItem => {
return typeof value === 'object' && value !== null
&& 'id' in value && typeof value.id === 'string'
&& 'quantity' in value && typeof value.quantity === 'number';
};

const decodeCart = (value: unknown): CartState | null => {
if (typeof value !== 'object' || value === null) return null;
if (!('items' in value) || !Array.isArray(value.items) || !value.items.every(isCartItem)) return null;
if (!('coupon' in value) || (value.coupon !== null && typeof value.coupon !== 'string')) return null;
return { items: value.items, coupon: value.coupon };
};

const cartStore = pipe
.use(persist({ local: 'cart', decode: decodeCart }))
.use(logger({ collapsed: true, diff: true }))
.use(devtools('cart'))
.create<CartState>(initialCartState);

export const useCart = create<CartState, CartAction>(reduceCart, cartStore);
```
Expand Down
2 changes: 1 addition & 1 deletion content/docs/state/index.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function Counter() {
@ilokesto/state
```

사용하는 프레임워크 peer만 함께 설치하세요. 예를 들어 `react`, `vue`, `svelte`, `solid-js`, `@angular/core` 중 실제 adapter에 필요한 것만 설치하면 됩니다. `@ilokesto/state/utils`의 `adaptor`를 사용할 때만 `immer`를 추가하세요.
사용하는 프레임워크 peer만 함께 설치하세요. 예를 들어 `react`, `vue`, `svelte`, `solid-js`, `@angular/core` 중 실제 adapter에 필요한 것만 설치하면 됩니다. `@ilokesto/state/adaptor`의 `adaptor`를 사용할 때만 `immer`를 추가하세요.


## 이 패키지가 하지 않는 것
Expand Down
2 changes: 1 addition & 1 deletion content/docs/state/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Use this package when you want one small store model with adapter-specific retur
@ilokesto/state
```

Install the framework peer you use, such as `react`, `vue`, `svelte`, `solid-js`, or `@angular/core`. Install `immer` only when you use `adaptor` from `@ilokesto/state/utils`.
Install the framework peer you use, such as `react`, `vue`, `svelte`, `solid-js`, or `@angular/core`. Install `immer` only when you use `adaptor` from `@ilokesto/state/adaptor`.


## What this package is not
Expand Down
8 changes: 8 additions & 0 deletions content/docs/state/integrations/react.ko.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ description: "React 컴포넌트와 hook에서 @ilokesto/state를 사용합니

React component가 `@ilokesto/store` 기반 상태를 구독해야 할 때 React adapter를 사용하세요. root package가 아니라 `@ilokesto/state/react`에서 import합니다.

## Adapter type

`UseState`와 `UseReducer`는 plain-state와 reducer `create()` overload가 반환하는 hook을 나타냅니다. public API가 이 hook type을 받을 때 같은 React subpath에서 import하세요.

```ts lineNumbers
import type { UseReducer, UseState } from '@ilokesto/state/react';
```

## Plain state hook

plain state는 작은 React state hook처럼 `[selection, setState]`를 반환합니다.
Expand Down
Loading