diff --git a/content/docs/state/advanced/persist-migrations.ko.mdx b/content/docs/state/advanced/persist-migrations.ko.mdx index b6b9920..dd97a60 100644 --- a/content/docs/state/advanced/persist-migrations.ko.mdx +++ b/content/docs/state/advanced/persist-migrations.ko.mdx @@ -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 = (old) => ({ + theme: typeof old === 'string' ? old : 'light', +}); +const toCurrent: PersistMigration = (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({ theme: 'light', count: 0 }); ``` ## 주의할 점 diff --git a/content/docs/state/advanced/persist-migrations.mdx b/content/docs/state/advanced/persist-migrations.mdx index 02bc4b5..006f742 100644 --- a/content/docs/state/advanced/persist-migrations.mdx +++ b/content/docs/state/advanced/persist-migrations.mdx @@ -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 = (old) => ({ + theme: typeof old === 'string' ? old : 'light', +}); +const toCurrent: PersistMigration = (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({ theme: 'light', count: 0 }); ``` ## Caveats diff --git a/content/docs/state/advanced/selector-semantics.ko.mdx b/content/docs/state/advanced/selector-semantics.ko.mdx index eab23c5..0e870f5 100644 --- a/content/docs/state/advanced/selector-semantics.ko.mdx +++ b/content/docs/state/advanced/selector-semantics.ko.mdx @@ -5,15 +5,31 @@ 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`이고 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 @@ -21,4 +37,4 @@ Svelte `select`는 subscription update마다 selector를 실행하는 readable s ## 주의할 점 -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`으로 감싸세요. \ No newline at end of file diff --git a/content/docs/state/advanced/selector-semantics.mdx b/content/docs/state/advanced/selector-semantics.mdx index 3a860b0..10eeca4 100644 --- a/content/docs/state/advanced/selector-semantics.mdx +++ b/content/docs/state/advanced/selector-semantics.mdx @@ -5,15 +5,31 @@ 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`, 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 @@ -21,4 +37,4 @@ Svelte `select` creates a readable store that runs the selector for each subscri ## 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. \ No newline at end of file diff --git a/content/docs/state/guides/lifecycle-reads-writes.ko.mdx b/content/docs/state/guides/lifecycle-reads-writes.ko.mdx index 2e106ec..ced3b3b 100644 --- a/content/docs/state/guides/lifecycle-reads-writes.ko.mdx +++ b/content/docs/state/guides/lifecycle-reads-writes.ko.mdx @@ -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 diff --git a/content/docs/state/guides/lifecycle-reads-writes.mdx b/content/docs/state/guides/lifecycle-reads-writes.mdx index 85c6307..7b70f49 100644 --- a/content/docs/state/guides/lifecycle-reads-writes.mdx +++ b/content/docs/state/guides/lifecycle-reads-writes.mdx @@ -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. 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. +`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 diff --git a/content/docs/state/guides/plain-state.ko.mdx b/content/docs/state/guides/plain-state.ko.mdx index 6501d88..a034460 100644 --- a/content/docs/state/guides/plain-state.ko.mdx +++ b/content/docs/state/guides/plain-state.ko.mdx @@ -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(initialSearchState); export const useSearch = create(searchStore); ``` diff --git a/content/docs/state/guides/plain-state.mdx b/content/docs/state/guides/plain-state.mdx index cf51d3c..5dcd74c 100644 --- a/content/docs/state/guides/plain-state.mdx +++ b/content/docs/state/guides/plain-state.mdx @@ -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(initialSearchState); export const useSearch = create(searchStore); ``` diff --git a/content/docs/state/guides/reducer-state.ko.mdx b/content/docs/state/guides/reducer-state.ko.mdx index 1367ff2..7d6e094 100644 --- a/content/docs/state/guides/reducer-state.ko.mdx +++ b/content/docs/state/guides/reducer-state.ko.mdx @@ -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(initialCartState); export const useCart = create(reduceCart, cartStore); ``` diff --git a/content/docs/state/guides/reducer-state.mdx b/content/docs/state/guides/reducer-state.mdx index 0a4d4a6..af34d2b 100644 --- a/content/docs/state/guides/reducer-state.mdx +++ b/content/docs/state/guides/reducer-state.mdx @@ -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(initialCartState); export const useCart = create(reduceCart, cartStore); ``` diff --git a/content/docs/state/index.ko.mdx b/content/docs/state/index.ko.mdx index 78bf4af..10dcf9f 100644 --- a/content/docs/state/index.ko.mdx +++ b/content/docs/state/index.ko.mdx @@ -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`를 추가하세요. ## 이 패키지가 하지 않는 것 diff --git a/content/docs/state/index.mdx b/content/docs/state/index.mdx index 1133932..bd4eed5 100644 --- a/content/docs/state/index.mdx +++ b/content/docs/state/index.mdx @@ -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 diff --git a/content/docs/state/integrations/react.ko.mdx b/content/docs/state/integrations/react.ko.mdx index cb6b729..1930358 100644 --- a/content/docs/state/integrations/react.ko.mdx +++ b/content/docs/state/integrations/react.ko.mdx @@ -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]`를 반환합니다. diff --git a/content/docs/state/integrations/react.mdx b/content/docs/state/integrations/react.mdx index 7d9309b..c7f3c62 100644 --- a/content/docs/state/integrations/react.mdx +++ b/content/docs/state/integrations/react.mdx @@ -7,6 +7,14 @@ description: "Use @ilokesto/state from React components and hooks." Use the React adapter when a React component needs a subscribed value from an `@ilokesto/store`-backed state container. Import from `@ilokesto/state/react`, not from the root package. +## Adapter types + +`UseState` and `UseReducer` describe the hooks returned by the plain-state and reducer `create()` overloads. Import them from the same React subpath when a public API accepts either hook type. + +```ts lineNumbers +import type { UseReducer, UseState } from '@ilokesto/state/react'; +``` + ## Plain state hook Plain state returns the same shape as a small React state hook: `[selection, setState]`. diff --git a/content/docs/state/middleware/debounce.ko.mdx b/content/docs/state/middleware/debounce.ko.mdx index b4697ae..2226766 100644 --- a/content/docs/state/middleware/debounce.ko.mdx +++ b/content/docs/state/middleware/debounce.ko.mdx @@ -10,22 +10,47 @@ description: "빠른 update를 지연하고 누적된 최신 state를 적용합 ## Signature ```ts lineNumbers -debounce(initialState: T | Store, wait: number | undefined): Store -debounce(wait?: number): (initialState: T | Store) => Store +debounce(wait?: number): PipeAnyMiddleware ``` ## 예제 ```ts lineNumbers import { debounce } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; -const store = debounce({ query: '' }, 250); +const store = pipe + .use(debounce(250)) + .create({ query: '' }); store.setState({ query: 'i' }); store.setState({ query: 'il' }); store.setState({ query: 'ilo' }); ``` +## persist와 함께 사용하기 + +두 middleware를 함께 사용할 때는 persistence가 debounced commit을 관찰하도록 `debounce`를 `persist`보다 먼저 선언하세요. + +```ts lineNumbers +import { debounce, persist } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +type SearchState = { readonly query: string }; + +const decodeSearch = (value: unknown): SearchState | null => { + if (typeof value !== 'object' || value === null || !('query' in value)) return null; + return typeof value.query === 'string' ? { query: value.query } : null; +}; + +const store = pipe + .use(debounce(250)) + .use(persist({ local: 'search', decode: decodeSearch })) + .create({ query: '' }); +``` + +`pipe.use(persist(...)).use(debounce(...))`는 지연된 commit의 persistence를 건너뛰므로 거부됩니다. + ## Function update function update는 순서를 유지한 채 저장되고, timer가 실행될 때 누적된 current state를 기준으로 replay됩니다. 그래서 debounce window 안에서도 updater function끼리 조합됩니다. diff --git a/content/docs/state/middleware/debounce.mdx b/content/docs/state/middleware/debounce.mdx index 9defc08..ff3341d 100644 --- a/content/docs/state/middleware/debounce.mdx +++ b/content/docs/state/middleware/debounce.mdx @@ -10,22 +10,47 @@ description: "Delay rapid updates and apply the latest accumulated state." ## Signature ```ts lineNumbers -debounce(initialState: T | Store, wait: number | undefined): Store -debounce(wait?: number): (initialState: T | Store) => Store +debounce(wait?: number): PipeAnyMiddleware ``` ## Example ```ts lineNumbers import { debounce } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; -const store = debounce({ query: '' }, 250); +const store = pipe + .use(debounce(250)) + .create({ query: '' }); store.setState({ query: 'i' }); store.setState({ query: 'il' }); store.setState({ query: 'ilo' }); ``` +## Using with persist + +When both middleware are used, declare `debounce` before `persist` so persistence observes the debounced commit: + +```ts lineNumbers +import { debounce, persist } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +type SearchState = { readonly query: string }; + +const decodeSearch = (value: unknown): SearchState | null => { + if (typeof value !== 'object' || value === null || !('query' in value)) return null; + return typeof value.query === 'string' ? { query: value.query } : null; +}; + +const store = pipe + .use(debounce(250)) + .use(persist({ local: 'search', decode: decodeSearch })) + .create({ query: '' }); +``` + +`pipe.use(persist(...)).use(debounce(...))` is rejected because it would skip persistence for deferred commits. + ## Function updates Function updates are kept in order and replayed against an accumulated current state when the timer fires. That means updater functions still compose with one another inside the debounce window. diff --git a/content/docs/state/middleware/devtools.ko.mdx b/content/docs/state/middleware/devtools.ko.mdx index dd7b7f6..923640b 100644 --- a/content/docs/state/middleware/devtools.ko.mdx +++ b/content/docs/state/middleware/devtools.ko.mdx @@ -10,8 +10,7 @@ description: "개발 환경에서 state update를 Redux DevTools extension에 ## Signature ```ts lineNumbers -devtools(initialState: T | Store, name: string): Store -devtools(name: string): (initialState: T | Store) => Store +devtools(name: string): PipeAnyMiddleware ``` ## 예제 @@ -20,7 +19,10 @@ devtools(name: string): (initialState: T | Store) => Store import { devtools, logger } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const store = pipe({ count: 0 }, devtools('counter'), logger()); +const store = pipe + .use(devtools('counter')) + .use(logger()) + .create({ count: 0 }); ``` ## 지원하는 DevTools action diff --git a/content/docs/state/middleware/devtools.mdx b/content/docs/state/middleware/devtools.mdx index 68cebf4..a8da7aa 100644 --- a/content/docs/state/middleware/devtools.mdx +++ b/content/docs/state/middleware/devtools.mdx @@ -10,8 +10,7 @@ description: "Connect state updates to the Redux DevTools extension in developme ## Signature ```ts lineNumbers -devtools(initialState: T | Store, name: string): Store -devtools(name: string): (initialState: T | Store) => Store +devtools(name: string): PipeAnyMiddleware ``` ## Example @@ -20,7 +19,10 @@ devtools(name: string): (initialState: T | Store) => Store import { devtools, logger } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const store = pipe({ count: 0 }, devtools('counter'), logger()); +const store = pipe + .use(devtools('counter')) + .use(logger()) + .create({ count: 0 }); ``` ## Supported DevTools actions diff --git a/content/docs/state/middleware/index.ko.mdx b/content/docs/state/middleware/index.ko.mdx index ba602a7..d3666ae 100644 --- a/content/docs/state/middleware/index.ko.mdx +++ b/content/docs/state/middleware/index.ko.mdx @@ -5,10 +5,9 @@ description: "@ilokesto/state middleware를 조합하는 방식과 각 helper를 # 미들웨어 소개 -`@ilokesto/state/middleware`는 `@ilokesto/store` middleware를 더 쉽게 붙이기 위한 작은 helper 모음입니다. 각 helper는 두 방식으로 사용할 수 있습니다. +`@ilokesto/state/middleware`는 `@ilokesto/store` middleware를 더 쉽게 붙이기 위한 작은 helper 모음입니다. 각 helper는 등록된 pipe middleware를 반환합니다. 반환값을 `pipe.use(...)`에 넘기고, middleware가 더 있으면 `.use()`를 이어 붙인 뒤 `.create(initialState)`로 plain state에서 store를 만드세요. -- 즉시 적용: `initialState` 또는 기존 `Store`를 먼저 넘기고 `Store`를 받습니다. -- curried 방식: option을 먼저 넘기고 `pipe`에서 조합할 수 있는 함수를 받습니다. +Public composition API는 builder-only입니다. `pipe`는 호출할 수 없으며 middleware helper는 `initialState`나 기존 `Store`를 즉시 받는 생성 mode를 제공하지 않습니다. ## 미들웨어가 실행되는 시점 @@ -32,12 +31,16 @@ const schema = { }, } as const; -const store = pipe( - { count: 0 }, - validate(schema), - logger({ collapsed: true, diff: true }), - persist({ local: 'counter' }), -); +const decodeCounter = (value: unknown): { count: number } | null => { + const result = schema['~standard'].validate(value); + return 'value' in result ? result.value : null; +}; + +const store = pipe + .use(validate(schema)) + .use(logger({ collapsed: true, diff: true })) + .use(persist({ local: 'counter', decode: decodeCounter })) + .create({ count: 0 }); ``` ## 제공되는 미들웨어 @@ -52,4 +55,4 @@ const store = pipe( ## 순서 잡는 법 -invalid state가 절대 저장되면 안 된다면 validation을 persistence보다 앞에 두세요. 앞선 middleware를 통과한 실제 state를 보고 싶다면 logger를 뒤쪽에 두는 편이 좋습니다. debounce를 사용하면 그 뒤의 모든 흐름에 timing 변화가 생긴다는 점을 기억하세요. +invalid state가 절대 저장되면 안 된다면 validation을 persistence보다 앞에 두세요. 앞선 middleware를 통과한 실제 state를 보고 싶다면 logger를 뒤쪽에 두는 편이 좋습니다. debounce를 사용하면 그 뒤의 모든 흐름에 timing 변화가 생긴다는 점을 기억하세요. debounce와 persist를 함께 사용할 때는 `pipe.use(debounce(...)).use(persist(...))` 순서로 선언하세요. 반대 순서는 `MIDDLEWARE_ORDER`로 거부됩니다. diff --git a/content/docs/state/middleware/index.mdx b/content/docs/state/middleware/index.mdx index d241a4f..1ec5c97 100644 --- a/content/docs/state/middleware/index.mdx +++ b/content/docs/state/middleware/index.mdx @@ -5,10 +5,9 @@ description: "How @ilokesto/state middleware is composed and when each helper fi # Middleware introduction -`@ilokesto/state/middleware` provides small wrappers around `@ilokesto/store` middleware. Each helper can be used in two styles: +`@ilokesto/state/middleware` provides small wrappers around `@ilokesto/store` middleware. Each helper returns registered pipe middleware. Pass that value to `pipe.use(...)`, add more middleware with additional `.use()` calls, then create a store from plain state with `.create(initialState)`. -- immediate style: pass `initialState` or an existing `Store` first and get a `Store` back, -- curried style: pass options first and get a function that can be composed with `pipe`. +The public composition API is builder-only. `pipe` is not callable, and middleware helpers do not expose an immediate `initialState` or existing-`Store` construction mode. ## When middleware runs @@ -32,12 +31,16 @@ const schema = { }, } as const; -const store = pipe( - { count: 0 }, - validate(schema), - logger({ collapsed: true, diff: true }), - persist({ local: 'counter' }), -); +const decodeCounter = (value: unknown): { count: number } | null => { + const result = schema['~standard'].validate(value); + return 'value' in result ? result.value : null; +}; + +const store = pipe + .use(validate(schema)) + .use(logger({ collapsed: true, diff: true })) + .use(persist({ local: 'counter', decode: decodeCounter })) + .create({ count: 0 }); ``` ## Available middleware @@ -52,4 +55,4 @@ const store = pipe( ## Ordering advice -Put validation before persistence when invalid state should never be stored. Put logger near the end when you want to see the state that actually passed earlier middleware. If debounce is used, remember it changes timing for everything after it. +Put validation before persistence when invalid state should never be stored. Put logger near the end when you want to see the state that actually passed earlier middleware. If debounce is used, remember it changes timing for everything after it. When both debounce and persist are used, declare `pipe.use(debounce(...)).use(persist(...))`; the reverse is rejected with `MIDDLEWARE_ORDER`. diff --git a/content/docs/state/middleware/logger.ko.mdx b/content/docs/state/middleware/logger.ko.mdx index a8f3784..647072c 100644 --- a/content/docs/state/middleware/logger.ko.mdx +++ b/content/docs/state/middleware/logger.ko.mdx @@ -10,8 +10,7 @@ description: "개발 중 state update를 로그로 확인합니다." ## Signature ```ts lineNumbers -logger(initialState: T | Store, options?: LoggerOptions): Store -logger(options?: LoggerOptions): (initialState: T | Store) => Store +logger(options?: LoggerOptions): PipeAnyMiddleware type LoggerOptions = { collapsed?: boolean; @@ -24,8 +23,11 @@ type LoggerOptions = { ```ts lineNumbers import { logger } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; -const store = logger({ count: 0 }, { collapsed: true, diff: true, timestamp: true }); +const store = pipe + .use(logger({ collapsed: true, diff: true, timestamp: true })) + .create({ count: 0 }); store.setState((state) => ({ count: state.count + 1 })); ``` diff --git a/content/docs/state/middleware/logger.mdx b/content/docs/state/middleware/logger.mdx index 78545b4..307216a 100644 --- a/content/docs/state/middleware/logger.mdx +++ b/content/docs/state/middleware/logger.mdx @@ -10,8 +10,7 @@ description: "Log state updates during development." ## Signature ```ts lineNumbers -logger(initialState: T | Store, options?: LoggerOptions): Store -logger(options?: LoggerOptions): (initialState: T | Store) => Store +logger(options?: LoggerOptions): PipeAnyMiddleware type LoggerOptions = { collapsed?: boolean; @@ -24,8 +23,11 @@ type LoggerOptions = { ```ts lineNumbers import { logger } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; -const store = logger({ count: 0 }, { collapsed: true, diff: true, timestamp: true }); +const store = pipe + .use(logger({ collapsed: true, diff: true, timestamp: true })) + .create({ count: 0 }); store.setState((state) => ({ count: state.count + 1 })); ``` diff --git a/content/docs/state/middleware/persist.ko.mdx b/content/docs/state/middleware/persist.ko.mdx index 09a5c4b..bc0fc67 100644 --- a/content/docs/state/middleware/persist.ko.mdx +++ b/content/docs/state/middleware/persist.ko.mdx @@ -10,41 +10,109 @@ description: "store state를 localStorage, sessionStorage, cookie에 저장합 ## Signature ```ts lineNumbers -persist>( - initialState: T | Store, - options: PersistConfig, -): Store -persist(options): (initialState: T | Store) => Store +persist( + options: SafePersistConfig, +): PipeMiddleware ``` +`persist(options)`는 `pipe.use(...)`에 등록할 middleware를 반환합니다. 등록한 뒤 `.create(initialState)`로 store를 만드세요. Storage에서 읽은 값을 live state로 사용하기 전에 검증하도록 `decode`가 필수입니다. + ## Local storage 예제 ```ts lineNumbers import { persist } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +type ThemeState = { theme: 'light' | 'dark' }; + +const decodeTheme = (value: unknown): ThemeState | null => { + if (typeof value !== 'object' || value === null) return null; + if (!('theme' in value) || (value.theme !== 'light' && value.theme !== 'dark')) return null; + return { theme: value.theme }; +}; + +const store = pipe + .use(persist({ local: 'theme', decode: decodeTheme })) + .create({ theme: 'light' }); +``` + +## debounce와 함께 사용하기 + +두 middleware를 함께 사용할 때는 persistence가 debounced commit을 관찰하도록 `debounce`를 `persist`보다 먼저 선언하세요. + +```ts lineNumbers +import { debounce, persist } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +type ThemeState = { readonly theme: 'light' | 'dark' }; + +const decodeTheme = (value: unknown): ThemeState | null => { + if (typeof value !== 'object' || value === null || !('theme' in value)) return null; + return value.theme === 'light' || value.theme === 'dark' ? { theme: value.theme } : null; +}; -const store = persist({ theme: 'light' as 'light' | 'dark' }, { local: 'theme' }); +const store = pipe + .use(debounce(250)) + .use(persist({ local: 'theme', decode: decodeTheme })) + .create({ theme: 'light' }); ``` +`pipe.use(persist(...)).use(debounce(...))`는 지연된 commit의 persistence를 건너뛰므로 거부됩니다. + ## Session과 cookie storage ```ts lineNumbers -const sessionStore = persist({ token: null as string | null }, { session: 'session' }); -const cookieStore = persist({ accepted: false }, { cookie: 'cookie-consent' }); +type SessionState = { token: string | null }; +type ConsentState = { accepted: boolean }; + +const decodeSession = (value: unknown): SessionState | null => { + if (typeof value !== 'object' || value === null || !('token' in value)) return null; + return typeof value.token === 'string' || value.token === null ? { token: value.token } : null; +}; + +const decodeConsent = (value: unknown): ConsentState | null => { + if (typeof value !== 'object' || value === null || !('accepted' in value)) return null; + return typeof value.accepted === 'boolean' ? { accepted: value.accepted } : null; +}; + +const sessionStore = pipe + .use(persist({ session: 'session', decode: decodeSession })) + .create({ token: null }); + +const cookieStore = pipe + .use(persist({ cookie: 'cookie-consent', decode: decodeConsent })) + .create({ accepted: false }); ``` ## Migration 예제 ```ts lineNumbers -const settings = persist( - { theme: 'light', count: 0 }, - { +import type { PersistMigration } from '@ilokesto/state/middleware'; + +type SettingsV1 = { theme: string }; +type SettingsState = { theme: string; count: number }; + +const toV1: PersistMigration = (old) => ({ + theme: typeof old === 'string' ? old : 'light', +}); +const toCurrent: PersistMigration = (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 settings = pipe + .use(persist({ local: 'settings', - migrate: [ - (old) => ({ theme: String(old), count: 0 }), - (old) => ({ ...old, count: Number(old.count ?? 0) }), - ], - }, -); + migrate: [toV1, toCurrent], + decode: decodeSettings, + })) + .create({ theme: 'light', count: 0 }); ``` ## Storage format과 주의점 @@ -52,5 +120,68 @@ const settings = persist( - 값은 `{ state, version }` JSON으로 저장됩니다. - `version`은 migration array 길이를 기준으로 합니다. - migration은 `local`과 `cookie`에서 실행되고 `session`에서는 실행되지 않습니다. -- storage read/write 실패는 browser에서 catch 후 log됩니다. -- cookie write는 단순한 `document.cookie = key=value` assignment입니다. 고급 cookie attribute가 필요하면 helper 밖에서 설정하세요. +- storage read 실패는 현재 live state를 유지하고 `onRehydrateStorage`에 전달됩니다. write 실패는 browser에서 catch 후 log됩니다. +- cookie write는 `document.cookie = key=value; path=/` assignment를 사용하여 모든 route에서 cookie가 보이도록 합니다. 고급 cookie attribute가 필요하면 helper 밖에서 설정하세요. + +## SSR과 hydration + +`persist`는 서버에서 평가해도 안전합니다. `window`나 `document`가 없는 환경에서 storage 읽기는 `null`을 반환하므로 store는 initial state를 유지합니다. + +기본적으로 `persist`는 eager hydration을 사용합니다 — store 생성 시점에 storage를 읽고 영속값을 적용합니다. client-only SPA에서는 문제가 없지만, Next.js App Router 같은 SSR 환경에서는 eager hydration이 React hydration mismatch를 발생시킵니다. 서버는 initial state로 렌더하지만 클라이언트는 영속값으로 렌더하기 때문입니다. + +### `skipHydration`과 수동 `rehydrate()` + +`skipHydration: true`를 전달해 hydration을 지연시킵니다. `store.persist.rehydrate()`을 명시적으로 호출하기 전까지 store는 서버와 클라이언트 모두에서 initial state를 유지합니다. + +```ts lineNumbers +import { persist } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +type CounterState = { count: number }; + +const decodeCounter = (value: unknown): CounterState | null => { + if (typeof value !== 'object' || value === null) return null; + if (!('count' in value) || typeof value.count !== 'number') return null; + return { count: value.count }; +}; + +const counterStore = pipe + .use(persist({ local: 'counter', decode: decodeCounter, skipHydration: true })) + .create({ count: 0 }); + +// React client component에서: +useEffect(() => { + counterStore.persist.rehydrate(); +}, []); +``` + +### `hasHydrated()` + +`store.persist.hasHydrated()`로 hydration 완료 후에만 렌더해야 하는 UI를 게이트할 수 있습니다. + +```ts lineNumbers +const hydrated = counterStore.persist.hasHydrated(); +``` + +### `onRehydrateStorage` + +`onRehydrateStorage`를 전달해 eager 또는 manual hydration을 관찰할 수 있습니다. factory는 storage를 읽기 전에 실행되며 hydration 직전의 live state를 받습니다. factory가 반환한 callback은 hydration 시도가 끝난 뒤 정확히 한 번 실행됩니다. + +성공 시 callback은 hydrated state와 `undefined`를 받습니다. storage가 비어 있는 경우도 성공이며 hydration 직전의 live state를 유지합니다. storage read, parsing, migration, decoding 실패 시에도 live state를 유지하고 callback에는 `undefined`와 원래 error가 전달됩니다. post callback 안에서는 `store.persist.hasHydrated()`가 이미 `true`입니다. post callback 자체가 throw하면 그 exception은 그대로 전파되며 같은 callback의 error 인자로 다시 전달되지 않습니다. + +```ts lineNumbers +const themeStore = pipe + .use(persist({ + local: 'theme', + decode: decodeTheme, + skipHydration: true, + onRehydrateStorage: (state) => (rehydratedState, error) => { + if (error) { + console.error('Rehydration failed', error); + return; + } + console.log('Rehydrated from', state, 'to', rehydratedState); + }, + })) + .create({ theme: 'light' }); +``` diff --git a/content/docs/state/middleware/persist.mdx b/content/docs/state/middleware/persist.mdx index 18ccc2d..dcba1ba 100644 --- a/content/docs/state/middleware/persist.mdx +++ b/content/docs/state/middleware/persist.mdx @@ -10,41 +10,109 @@ description: "Persist store state to localStorage, sessionStorage, or cookies." ## Signature ```ts lineNumbers -persist>( - initialState: T | Store, - options: PersistConfig, -): Store -persist(options): (initialState: T | Store) => Store +persist( + options: SafePersistConfig, +): PipeMiddleware ``` +`persist(options)` returns registered middleware for `pipe.use(...)`. Create the store with `.create(initialState)` after registering it. `decode` is required so values read from storage are validated before they become live state. + ## Local storage example ```ts lineNumbers import { persist } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +type ThemeState = { theme: 'light' | 'dark' }; + +const decodeTheme = (value: unknown): ThemeState | null => { + if (typeof value !== 'object' || value === null) return null; + if (!('theme' in value) || (value.theme !== 'light' && value.theme !== 'dark')) return null; + return { theme: value.theme }; +}; + +const store = pipe + .use(persist({ local: 'theme', decode: decodeTheme })) + .create({ theme: 'light' }); +``` + +## Using with debounce + +When both middleware are used, declare `debounce` before `persist` so persistence observes the debounced commit: + +```ts lineNumbers +import { debounce, persist } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +type ThemeState = { readonly theme: 'light' | 'dark' }; + +const decodeTheme = (value: unknown): ThemeState | null => { + if (typeof value !== 'object' || value === null || !('theme' in value)) return null; + return value.theme === 'light' || value.theme === 'dark' ? { theme: value.theme } : null; +}; -const store = persist({ theme: 'light' as 'light' | 'dark' }, { local: 'theme' }); +const store = pipe + .use(debounce(250)) + .use(persist({ local: 'theme', decode: decodeTheme })) + .create({ theme: 'light' }); ``` +`pipe.use(persist(...)).use(debounce(...))` is rejected because it would skip persistence for deferred commits. + ## Session and cookie storage ```ts lineNumbers -const sessionStore = persist({ token: null as string | null }, { session: 'session' }); -const cookieStore = persist({ accepted: false }, { cookie: 'cookie-consent' }); +type SessionState = { token: string | null }; +type ConsentState = { accepted: boolean }; + +const decodeSession = (value: unknown): SessionState | null => { + if (typeof value !== 'object' || value === null || !('token' in value)) return null; + return typeof value.token === 'string' || value.token === null ? { token: value.token } : null; +}; + +const decodeConsent = (value: unknown): ConsentState | null => { + if (typeof value !== 'object' || value === null || !('accepted' in value)) return null; + return typeof value.accepted === 'boolean' ? { accepted: value.accepted } : null; +}; + +const sessionStore = pipe + .use(persist({ session: 'session', decode: decodeSession })) + .create({ token: null }); + +const cookieStore = pipe + .use(persist({ cookie: 'cookie-consent', decode: decodeConsent })) + .create({ accepted: false }); ``` ## Migration example ```ts lineNumbers -const settings = persist( - { theme: 'light', count: 0 }, - { +import type { PersistMigration } from '@ilokesto/state/middleware'; + +type SettingsV1 = { theme: string }; +type SettingsState = { theme: string; count: number }; + +const toV1: PersistMigration = (old) => ({ + theme: typeof old === 'string' ? old : 'light', +}); +const toCurrent: PersistMigration = (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 settings = pipe + .use(persist({ local: 'settings', - migrate: [ - (old) => ({ theme: String(old), count: 0 }), - (old) => ({ ...old, count: Number(old.count ?? 0) }), - ], - }, -); + migrate: [toV1, toCurrent], + decode: decodeSettings, + })) + .create({ theme: 'light', count: 0 }); ``` ## Storage format and caveats @@ -52,5 +120,68 @@ const settings = persist( - Values are stored as `{ state, version }` JSON. - `version` is based on migration array length. - Migrations run for `local` and `cookie`, not `session`. -- Storage read/write failures are caught and logged in the browser. -- Cookie writing uses a simple `document.cookie = key=value` assignment; configure advanced cookie attributes outside this helper if needed. +- Storage read failures keep the live state and are passed to `onRehydrateStorage`; write failures are caught and logged in the browser. +- Cookie writing uses `document.cookie = key=value; path=/` so cookies are visible across all routes. Configure advanced cookie attributes outside this helper if needed. + +## SSR and hydration + +`persist` is safe to evaluate on the server. Storage reads return `null` when `window` or `document` is unavailable, so the store stays at its initial state during server rendering. + +By default, `persist` hydrates eagerly — it reads storage and applies the persisted value at store creation time. In a client-only SPA this is fine, but in SSR frameworks like Next.js App Router, eager hydration causes a React hydration mismatch: the server renders the initial state while the client renders the persisted state. + +### `skipHydration` and manual `rehydrate()` + +Pass `skipHydration: true` to defer hydration. The store keeps the initial state on both server and client until you call `store.persist.rehydrate()` explicitly. + +```ts lineNumbers +import { persist } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +type CounterState = { count: number }; + +const decodeCounter = (value: unknown): CounterState | null => { + if (typeof value !== 'object' || value === null) return null; + if (!('count' in value) || typeof value.count !== 'number') return null; + return { count: value.count }; +}; + +const counterStore = pipe + .use(persist({ local: 'counter', decode: decodeCounter, skipHydration: true })) + .create({ count: 0 }); + +// In a React client component: +useEffect(() => { + counterStore.persist.rehydrate(); +}, []); +``` + +### `hasHydrated()` + +Check `store.persist.hasHydrated()` to gate UI that should only render after hydration. + +```ts lineNumbers +const hydrated = counterStore.persist.hasHydrated(); +``` + +### `onRehydrateStorage` + +Pass `onRehydrateStorage` to observe eager or manual hydration. The factory runs before storage is read and receives the live pre-hydration state. Its returned callback runs exactly once after the attempt completes. + +On success, the callback receives the hydrated state and `undefined`. Empty storage is also successful and preserves the live pre-hydration state. Storage, parsing, migration, and decoding failures preserve that live state and call the callback with `undefined` and the original error. `store.persist.hasHydrated()` is already `true` inside the post callback. If the post callback throws, that exception propagates and is not passed back into the same callback. + +```ts lineNumbers +const themeStore = pipe + .use(persist({ + local: 'theme', + decode: decodeTheme, + skipHydration: true, + onRehydrateStorage: (state) => (rehydratedState, error) => { + if (error) { + console.error('Rehydration failed', error); + return; + } + console.log('Rehydrated from', state, 'to', rehydratedState); + }, + })) + .create({ theme: 'light' }); +``` diff --git a/content/docs/state/middleware/validate.ko.mdx b/content/docs/state/middleware/validate.ko.mdx index f51a252..d34fb97 100644 --- a/content/docs/state/middleware/validate.ko.mdx +++ b/content/docs/state/middleware/validate.ko.mdx @@ -5,19 +5,22 @@ description: "Standard Schema로 invalid synchronous state update를 막습니 # validate -`validate`는 store가 다음 state를 받아들이기 전에 Standard Schema v1 validator를 실행합니다. validation이 실패하면 update를 멈추고 error를 로그로 남깁니다. +`validate`는 store가 다음 state를 받아들이기 전에 Standard Schema v1 validator를 실행합니다. validation이 실패하면 update를 멈추고 `onError`를 호출합니다. ## Signature ```ts lineNumbers -validate(initialState: T | Store, schema: StandardSchemaV1): Store -validate(schema: StandardSchemaV1): (initialState: T | Store) => Store +validate( + schema: StandardSchemaV1, + options?: { onError?: (issues: ReadonlyArray) => void }, +): PipeMiddleware ``` ## 예제 ```ts lineNumbers import { validate } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; type CounterState = { count: number }; @@ -34,13 +37,27 @@ const schema = { }, } as const; -const store = validate({ count: 0 }, schema); +const store = pipe + .use(validate(schema)) + .create({ count: 0 }); ``` ## 실패하면 어떻게 되나 -schema가 `issues`를 반환하면 `validate`는 `[Validation Error] Invalid state:`를 로그로 남기고 다음 middleware를 호출하지 않습니다. store는 이전 state를 유지합니다. +schema가 `issues`를 반환하면 `validate`는 `onError`(기본값 `console.error`)를 호출하고 다음 middleware를 호출하지 않습니다. store는 이전 state를 유지합니다. + +### custom error handling + +`onError`를 전달하여 실패 동작을 제어할 수 있습니다. callback 안에서 throw하면 error가 `setState` 호출자에게 전파됩니다: + +```ts lineNumbers +const store = pipe + .use(validate(schema, { + onError: (issues) => { throw new Error(issues[0]?.message ?? 'Validation failed'); }, + })) + .create({ count: 0 }); +``` ## Async 주의점 -async Standard Schema validation은 지원하지 않습니다. `validate()`가 Promise-like 결과를 반환하면 async validation error를 로그로 남기고 update를 멈춥니다. async check는 `setState` 호출 전에 수행하세요. +async Standard Schema validation은 지원하지 않습니다. `validate()`가 Promise-like 결과를 반환하면 `onError`에 synthetic issue를 전달하고 update를 멈춥니다. async check는 `setState` 호출 전에 수행하세요. \ No newline at end of file diff --git a/content/docs/state/middleware/validate.mdx b/content/docs/state/middleware/validate.mdx index b15fa2a..cd34ea0 100644 --- a/content/docs/state/middleware/validate.mdx +++ b/content/docs/state/middleware/validate.mdx @@ -5,19 +5,22 @@ description: "Block invalid synchronous state updates with Standard Schema." # validate -`validate` runs a Standard Schema v1 validator before the store accepts the next state. If validation fails, the update is stopped and an error is logged. +`validate` runs a Standard Schema v1 validator before the store accepts the next state. If validation fails, the update is stopped and `onError` is called. ## Signature ```ts lineNumbers -validate(initialState: T | Store, schema: StandardSchemaV1): Store -validate(schema: StandardSchemaV1): (initialState: T | Store) => Store +validate( + schema: StandardSchemaV1, + options?: { onError?: (issues: ReadonlyArray) => void }, +): PipeMiddleware ``` ## Example ```ts lineNumbers import { validate } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; type CounterState = { count: number }; @@ -34,13 +37,27 @@ const schema = { }, } as const; -const store = validate({ count: 0 }, schema); +const store = pipe + .use(validate(schema)) + .create({ count: 0 }); ``` ## What happens on failure -When the schema returns `issues`, `validate` logs `[Validation Error] Invalid state:` and does not call the next middleware. The store keeps its previous state. +When the schema returns `issues`, `validate` calls `onError` (defaults to `console.error`) and does not call the next middleware. The store keeps its previous state. + +### Custom error handling + +Pass `onError` to control failure behavior. Throw inside the callback to propagate the error to the caller of `setState`: + +```ts lineNumbers +const store = pipe + .use(validate(schema, { + onError: (issues) => { throw new Error(issues[0]?.message ?? 'Validation failed'); }, + })) + .create({ count: 0 }); +``` ## Async caveat -Async Standard Schema validation is not supported. If `validate()` returns a Promise-like result, the middleware logs an async validation error and stops the update. Run async checks before calling `setState`. +Async Standard Schema validation is not supported. If `validate()` returns a Promise-like result, the middleware calls `onError` with a synthetic issue and stops the update. Run async checks before calling `setState`. \ No newline at end of file diff --git a/content/docs/state/reference/package-surface.ko.mdx b/content/docs/state/reference/package-surface.ko.mdx index 830a0f3..65c7c87 100644 --- a/content/docs/state/reference/package-surface.ko.mdx +++ b/content/docs/state/reference/package-surface.ko.mdx @@ -12,13 +12,14 @@ public package surface는 `state/package.json`의 exports가 기준입니다. | Import | Purpose | |---|---| | `@ilokesto/state` | package identity와 현재 empty runtime root입니다. source는 `export {}`입니다. | -| `@ilokesto/state/react` | React `create()` adapter입니다. | +| `@ilokesto/state/react` | React `create()` adapter와 `UseState`, `UseReducer` type입니다. | | `@ilokesto/state/vue` | Vue `create()` adapter입니다. | | `@ilokesto/state/svelte` | Svelte `create()` adapter입니다. | | `@ilokesto/state/solid` | Solid `create()` adapter입니다. | | `@ilokesto/state/angular` | Angular `create()` adapter입니다. | | `@ilokesto/state/middleware` | `logger`, `validate`, `debounce`, `devtools`, `persist`입니다. | -| `@ilokesto/state/utils` | `pipe`, `adaptor`입니다. | +| `@ilokesto/state/utils` | `pipe`입니다. | +| `@ilokesto/state/adaptor` | `adaptor`; optional `immer` peer가 필요합니다. | ## Root entrypoint 주의점 @@ -35,4 +36,4 @@ import { create } from '@ilokesto/state'; ## Peer dependencies -프레임워크 peer는 optional입니다. 실제로 쓰는 어댑터의 프레임워크만 설치하세요. `immer`는 `@ilokesto/state/utils`의 `adaptor`를 사용할 때만 필요합니다. +프레임워크 peer는 optional입니다. 실제로 쓰는 어댑터의 프레임워크만 설치하세요. `immer`는 `@ilokesto/state/adaptor`의 `adaptor`를 사용할 때만 필요합니다. diff --git a/content/docs/state/reference/package-surface.mdx b/content/docs/state/reference/package-surface.mdx index d36370c..4b7c7eb 100644 --- a/content/docs/state/reference/package-surface.mdx +++ b/content/docs/state/reference/package-surface.mdx @@ -12,13 +12,14 @@ The public package surface is defined by `state/package.json` exports. | Import | Purpose | |---|---| | `@ilokesto/state` | Package identity and current empty runtime root. The source is `export {}`. | -| `@ilokesto/state/react` | React `create()` adapter. | +| `@ilokesto/state/react` | React `create()` adapter and the `UseState` and `UseReducer` types. | | `@ilokesto/state/vue` | Vue `create()` adapter. | | `@ilokesto/state/svelte` | Svelte `create()` adapter. | | `@ilokesto/state/solid` | Solid `create()` adapter. | | `@ilokesto/state/angular` | Angular `create()` adapter. | | `@ilokesto/state/middleware` | `logger`, `validate`, `debounce`, `devtools`, `persist`. | -| `@ilokesto/state/utils` | `pipe`, `adaptor`. | +| `@ilokesto/state/utils` | `pipe`. | +| `@ilokesto/state/adaptor` | `adaptor`; requires the optional `immer` peer. | ## Root entrypoint caveat @@ -35,4 +36,4 @@ import { create } from '@ilokesto/state'; ## Peer dependencies -Framework peers are optional: install only the adapter framework you use. `immer` is optional and only needed when you use `adaptor` from `@ilokesto/state/utils`. +Framework peers are optional: install only the adapter framework you use. `immer` is optional and only needed when you use `adaptor` from `@ilokesto/state/adaptor`. diff --git a/content/docs/state/reference/read-write.ko.mdx b/content/docs/state/reference/read-write.ko.mdx index e97f0eb..15f6b42 100644 --- a/content/docs/state/reference/read-write.ko.mdx +++ b/content/docs/state/reference/read-write.ko.mdx @@ -9,7 +9,7 @@ description: "readOnly, writeOnly, subscribe와 생명주기 안전 사용법입 ## `readOnly()` -`readOnly()`는 underlying store를 동기적으로 읽습니다. selector를 넘길 수 있습니다. +`readOnly()`는 underlying store를 동기적으로 읽습니다. 전체 object-state read는 `Readonly`를 반환하고 selector도 이를 받습니다. callable state는 exact `T`로 유지되어 임의의 generic/overloaded signature와 선언된 own-property modifier를 보존합니다. selector를 넘길 수 있습니다. ```ts lineNumbers const current = useCounter.readOnly(); diff --git a/content/docs/state/reference/read-write.mdx b/content/docs/state/reference/read-write.mdx index 829d41c..436d536 100644 --- a/content/docs/state/reference/read-write.mdx +++ b/content/docs/state/reference/read-write.mdx @@ -9,7 +9,7 @@ description: "readOnly, writeOnly, subscribe, and lifecycle-safe usage." ## `readOnly()` -`readOnly()` synchronously reads from the underlying store. You can pass a selector. +`readOnly()` synchronously reads from the underlying store. Full object-state reads return `Readonly` and selectors receive it. Callable state remains exact `T`, preserving arbitrary generic and overloaded signatures and its own-property modifiers as declared. You can pass a selector. ```ts lineNumbers const current = useCounter.readOnly(); diff --git a/content/docs/state/troubleshooting.ko.mdx b/content/docs/state/troubleshooting.ko.mdx index 13bd142..43fd6f0 100644 --- a/content/docs/state/troubleshooting.ko.mdx +++ b/content/docs/state/troubleshooting.ko.mdx @@ -39,4 +39,4 @@ validate middleware는 async Standard Schema result를 지원하지 않습니다 ## `adaptor`를 import하거나 사용할 수 없습니다 -optional peer dependency인 `immer`를 설치하고 object state에서 `adaptor`를 사용하세요. +optional peer dependency인 `immer`를 설치하고, `@ilokesto/state/adaptor`에서 `adaptor`를 import한 뒤 object state에서 사용하세요. diff --git a/content/docs/state/troubleshooting.mdx b/content/docs/state/troubleshooting.mdx index 3361465..984ac4a 100644 --- a/content/docs/state/troubleshooting.mdx +++ b/content/docs/state/troubleshooting.mdx @@ -39,4 +39,4 @@ The validate middleware rejects async Standard Schema results. Keep validation s ## `adaptor` cannot be imported or used -Install the optional `immer` peer dependency and use object state with `adaptor`. +Install the optional `immer` peer dependency, import `adaptor` from `@ilokesto/state/adaptor`, and use it with object state. diff --git a/content/docs/state/utility/adaptor.ko.mdx b/content/docs/state/utility/adaptor.ko.mdx index 281c8d9..8644d3c 100644 --- a/content/docs/state/utility/adaptor.ko.mdx +++ b/content/docs/state/utility/adaptor.ko.mdx @@ -21,17 +21,17 @@ Immutable object update가 맞지만 코드가 장황해질 때 사용하세요. pnpm add immer ``` -그리고 utility subpath에서 `adaptor`를 import합니다. +그리고 전용 subpath에서 `adaptor`를 import합니다. ```ts lineNumbers -import { adaptor } from '@ilokesto/state/utils'; +import { adaptor } from '@ilokesto/state/adaptor'; ``` ## 기본 사용법 ```tsx lineNumbers import { create } from '@ilokesto/state/react'; -import { adaptor } from '@ilokesto/state/utils'; +import { adaptor } from '@ilokesto/state/adaptor'; type TodoState = { items: Array<{ id: string; title: string; done: boolean }>; @@ -111,13 +111,13 @@ Adapter는 여전히 일반 store update pipeline을 통해 subscriber에게 알 ```ts lineNumbers import { logger, validate } from '@ilokesto/state/middleware'; -import { adaptor, pipe } from '@ilokesto/state/utils'; +import { adaptor } from '@ilokesto/state/adaptor'; +import { pipe } from '@ilokesto/state/utils'; -const store = pipe( - { tags: [] as string[] }, - validate(tagsSchema), - logger({ diff: true }), -); +const store = pipe + .use(validate(tagsSchema)) + .use(logger({ diff: true })) + .create({ tags: [] as string[] }); const useTags = create(store); const writeTags = useTags.writeOnly(); diff --git a/content/docs/state/utility/adaptor.mdx b/content/docs/state/utility/adaptor.mdx index 5ea9d77..ed2738e 100644 --- a/content/docs/state/utility/adaptor.mdx +++ b/content/docs/state/utility/adaptor.mdx @@ -21,17 +21,17 @@ Use it when immutable object updates are correct but verbose. Instead of returni pnpm add immer ``` -Then import `adaptor` from the utility subpath. +Then import `adaptor` from its dedicated subpath. ```ts lineNumbers -import { adaptor } from '@ilokesto/state/utils'; +import { adaptor } from '@ilokesto/state/adaptor'; ``` ## Basic usage ```tsx lineNumbers import { create } from '@ilokesto/state/react'; -import { adaptor } from '@ilokesto/state/utils'; +import { adaptor } from '@ilokesto/state/adaptor'; type TodoState = { items: Array<{ id: string; title: string; done: boolean }>; @@ -111,13 +111,13 @@ The adapter still notifies subscribers through the normal store update pipeline. ```ts lineNumbers import { logger, validate } from '@ilokesto/state/middleware'; -import { adaptor, pipe } from '@ilokesto/state/utils'; +import { adaptor } from '@ilokesto/state/adaptor'; +import { pipe } from '@ilokesto/state/utils'; -const store = pipe( - { tags: [] as string[] }, - validate(tagsSchema), - logger({ diff: true }), -); +const store = pipe + .use(validate(tagsSchema)) + .use(logger({ diff: true })) + .create({ tags: [] as string[] }); const useTags = create(store); const writeTags = useTags.writeOnly(); diff --git a/content/docs/state/utility/index.ko.mdx b/content/docs/state/utility/index.ko.mdx index db97920..34b55dc 100644 --- a/content/docs/state/utility/index.ko.mdx +++ b/content/docs/state/utility/index.ko.mdx @@ -21,11 +21,19 @@ import { create } from '@ilokesto/state/react'; import { logger, persist } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const preferencesStore = pipe( - { theme: 'system' as 'system' | 'light' | 'dark' }, - persist({ local: 'preferences' }), - logger({ collapsed: true }), -); +type PreferencesState = { theme: 'system' | 'light' | 'dark' }; + +const decodePreferences = (value: unknown): PreferencesState | null => { + if (typeof value !== 'object' || value === null || !('theme' in value)) return null; + return value.theme === 'system' || value.theme === 'light' || value.theme === 'dark' + ? { theme: value.theme } + : null; +}; + +const preferencesStore = pipe + .use(persist({ local: 'preferences', decode: decodePreferences })) + .use(logger({ collapsed: true })) + .create({ theme: 'system' }); export const usePreferences = create(preferencesStore); ``` @@ -59,12 +67,12 @@ pnpm add immer ```tsx lineNumbers import { create } from '@ilokesto/state/react'; import { validate } from '@ilokesto/state/middleware'; -import { adaptor, pipe } from '@ilokesto/state/utils'; +import { adaptor } from '@ilokesto/state/adaptor'; +import { pipe } from '@ilokesto/state/utils'; -const profileStore = pipe( - { name: '', tags: [] as string[] }, - validate(profileSchema), -); +const profileStore = pipe + .use(validate(profileSchema)) + .create({ name: '', tags: [] as string[] }); const useProfile = create(profileStore); diff --git a/content/docs/state/utility/index.mdx b/content/docs/state/utility/index.mdx index 471a84e..105b13f 100644 --- a/content/docs/state/utility/index.mdx +++ b/content/docs/state/utility/index.mdx @@ -21,11 +21,19 @@ import { create } from '@ilokesto/state/react'; import { logger, persist } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const preferencesStore = pipe( - { theme: 'system' as 'system' | 'light' | 'dark' }, - persist({ local: 'preferences' }), - logger({ collapsed: true }), -); +type PreferencesState = { theme: 'system' | 'light' | 'dark' }; + +const decodePreferences = (value: unknown): PreferencesState | null => { + if (typeof value !== 'object' || value === null || !('theme' in value)) return null; + return value.theme === 'system' || value.theme === 'light' || value.theme === 'dark' + ? { theme: value.theme } + : null; +}; + +const preferencesStore = pipe + .use(persist({ local: 'preferences', decode: decodePreferences })) + .use(logger({ collapsed: true })) + .create({ theme: 'system' }); export const usePreferences = create(preferencesStore); ``` @@ -59,12 +67,12 @@ pnpm add immer ```tsx lineNumbers import { create } from '@ilokesto/state/react'; import { validate } from '@ilokesto/state/middleware'; -import { adaptor, pipe } from '@ilokesto/state/utils'; +import { adaptor } from '@ilokesto/state/adaptor'; +import { pipe } from '@ilokesto/state/utils'; -const profileStore = pipe( - { name: '', tags: [] as string[] }, - validate(profileSchema), -); +const profileStore = pipe + .use(validate(profileSchema)) + .create({ name: '', tags: [] as string[] }); const useProfile = create(profileStore); diff --git a/content/docs/state/utility/pipe.ko.mdx b/content/docs/state/utility/pipe.ko.mdx index 09be169..f75867d 100644 --- a/content/docs/state/utility/pipe.ko.mdx +++ b/content/docs/state/utility/pipe.ko.mdx @@ -5,13 +5,15 @@ description: "Adapter에 연결하기 전에 Store를 만들고 middleware를 # pipe -`pipe`는 `@ilokesto/state/utils`의 작은 composition helper입니다. +`pipe`는 `@ilokesto/state/utils`의 builder-only composition helper입니다. ```ts lineNumbers -pipe(initialState: T, ...middlewares: Array<(store: Store) => Store>): Store +pipe.use(middleware): PipeBuilder +PipeBuilder.use(middleware): PipeBuilder +PipeBuilder.create(initialState: T): Store ``` -`initialState`로 새 `Store`를 만들고, 각 middleware function을 왼쪽에서 오른쪽으로 적용합니다. 결과는 framework adapter에 넘길 수 있는 준비된 store입니다. +Root `pipe` object는 `.use()`만 제공합니다. 각 `.use()`는 다른 middleware를 등록하거나 `.create(initialState)`를 호출할 수 있는 builder를 반환합니다. `.create()`는 새 `Store`를 만들고 등록된 middleware를 선언 순서대로 적용한 뒤 framework adapter에 넘길 store를 반환합니다. ## 기본 사용법 @@ -20,51 +22,49 @@ import { create } from '@ilokesto/state/react'; import { logger, persist } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const counterStore = pipe( - { count: 0 }, - persist({ local: 'counter' }), - logger({ collapsed: true, diff: true }), -); +type CounterState = { count: number }; -export const useCounter = create(counterStore); -``` +const decodeCounter = (value: unknown): CounterState | null => { + if (typeof value !== 'object' || value === null) return null; + if (!('count' in value) || typeof value.count !== 'number') return null; + return { count: value.count }; +}; -위 순서는 persistence가 initial value를 준비하고, 그 다음 logger가 이후 update를 관찰한다는 뜻입니다. 각 middleware가 store를 받아 다시 반환하므로 순서가 중요합니다. +const counterStore = pipe + .use(persist({ local: 'counter', decode: decodeCounter })) + .use(logger({ collapsed: true, diff: true })) + .create({ count: 0 }); -## Middleware를 직접 호출하면 안 되나? +export const useCounter = create(counterStore); +``` -직접 조합할 수도 있습니다. +Middleware setup은 첫 번째 `.use()`부터 마지막 `.use()`까지 실행됩니다. Update에서는 처음 등록한 middleware가 가장 바깥쪽입니다. 순서가 중요하며 `pipe`는 선언 순서를 바꾸지 않고 검증합니다. -```ts lineNumbers -const store = logger({ collapsed: true })(persist({ local: 'counter' })({ count: 0 })); -``` +## Builder syntax를 쓰는 이유 -`pipe`는 같은 흐름을 더 읽기 쉽게 만들고, 코드에 보이는 순서와 runtime 순서를 맞춰줍니다. +`pipe`는 호출할 수 없고 variadic middleware list도 받지 않습니다. `.use()`마다 middleware 하나를 등록한 뒤 plain initial state로 store를 만드세요. ```ts lineNumbers -const store = pipe( - { count: 0 }, - persist({ local: 'counter' }), - logger({ collapsed: true }), -); +const store = pipe + .use(validate(counterSchema)) + .use(logger({ collapsed: true })) + .create({ count: 0 }); ``` -위에서 아래로 읽으면 됩니다: state 생성, persistence 적용, logging 적용. +위에서 아래로 읽으면 됩니다: validation 등록, logging 등록, state 생성. ## Validation과 함께 쓰기 Validation은 side effect를 수행하는 middleware보다 앞에 두는 경우가 많습니다. 그래야 invalid state가 persist되거나 tooling으로 전달되지 않습니다. ```ts lineNumbers -import { devtools, persist, validate } from '@ilokesto/state/middleware'; +import { devtools, validate } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const settingsStore = pipe( - { theme: 'system' as 'system' | 'light' | 'dark' }, - validate(settingsSchema), - persist({ local: 'settings' }), - devtools('settings'), -); +const settingsStore = pipe + .use(validate(settingsSchema)) + .use(devtools('settings')) + .create({ theme: 'system' as 'system' | 'light' | 'dark' }); ``` Validation이 update를 거부하면 뒤쪽 middleware는 invalid state를 보지 않아야 합니다. @@ -78,10 +78,9 @@ import { create } from '@ilokesto/state/react'; import { logger } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const todoStore = pipe( - { items: [] as string[] }, - logger({ diff: true }), -); +const todoStore = pipe + .use(logger({ diff: true })) + .create({ items: [] as string[] }); export const useTodos = create(reduceTodos, todoStore); ``` @@ -90,27 +89,26 @@ Reducer action은 next state로 변환되고, 그 결과 update가 store middlew ## 기존 Store instance -`pipe`는 항상 initial state에서 새 `Store`를 만듭니다. 이미 특정 `Store` instance를 소유하고 있고 그 instance를 보존해야 한다면 `pipe` 대신 middleware helper에 store를 직접 넘기세요. +`pipe`는 항상 plain initial state에서 새 `Store`를 만듭니다. `.create()`는 기존 `Store`를 거부합니다. 다른 module이 정확한 store instance를 이미 소유한다면 그 store를 framework adapter에 직접 넘기고, 생성에는 `pipe`를 사용하지 마세요. ```ts lineNumbers import { Store } from '@ilokesto/store'; -import { logger } from '@ilokesto/state/middleware'; +import { create } from '@ilokesto/state/react'; const existingStore = new Store({ count: 0 }); -const storeWithLogger = logger({ collapsed: true })(existingStore); +const useCounter = create(existingStore); ``` -다른 module이 이미 store를 subscribe하고 있거나, utility layer 밖 infrastructure가 store를 만든 경우 이 방식을 사용하세요. +다른 module이 이미 store를 subscribe하고 있거나 utility layer 밖 infrastructure가 store를 만든 경우 이 방식을 사용하세요. ## Piped store 테스트하기 `pipe`는 plain `Store`를 반환하므로 framework adapter 없이 먼저 test할 수 있습니다. ```ts lineNumbers -const store = pipe( - { count: 0 }, - validate(counterSchema), -); +const store = pipe + .use(validate(counterSchema)) + .create({ count: 0 }); store.setState({ count: 1 }); expect(store.getState()).toEqual({ count: 1 }); @@ -120,7 +118,8 @@ Persistence나 browser-only middleware는 필요한 storage API를 제공하는 ## 자주 하는 실수 -- **`pipe`가 기존 store를 mutate한다고 생각하기.** `pipe`는 `initialState`에서 새 `Store`를 만듭니다. -- **순서 무시하기.** Middleware는 왼쪽에서 오른쪽으로 적용되고, side-effect middleware는 보통 validation 뒤에 둡니다. +- **`pipe(initialState, ...middleware)` 호출하기.** `pipe`는 object입니다. `pipe.use(...)`로 시작하고 `.create(initialState)`로 끝내세요. +- **기존 store를 `.create()`에 넘기기.** Builder는 plain initial state만 받고 새 `Store`를 만듭니다. +- **순서 무시하기.** Middleware는 선언 순서대로 등록되며 side-effect middleware는 보통 validation 뒤에 둡니다. - **framework adapter call을 `pipe` 안에 넣기.** `pipe`는 store middleware를 조합하지 React/Vue/Svelte/Solid/Angular adapter call을 조합하지 않습니다. - **store 생성 후 update에 `pipe`를 사용하기.** Store가 이미 있으면 `setState`, `dispatch`, 또는 [`adaptor`](/ko/state/utility/adaptor)를 사용하세요. diff --git a/content/docs/state/utility/pipe.mdx b/content/docs/state/utility/pipe.mdx index 7e62d8e..b3a4a24 100644 --- a/content/docs/state/utility/pipe.mdx +++ b/content/docs/state/utility/pipe.mdx @@ -5,13 +5,15 @@ description: "Create a Store and apply middleware left-to-right before connectin # pipe -`pipe` is a small composition helper from `@ilokesto/state/utils`. +`pipe` is a builder-only composition helper from `@ilokesto/state/utils`. ```ts lineNumbers -pipe(initialState: T, ...middlewares: Array<(store: Store) => Store>): Store +pipe.use(middleware): PipeBuilder +PipeBuilder.use(middleware): PipeBuilder +PipeBuilder.create(initialState: T): Store ``` -It creates a new `Store` from `initialState`, then applies each middleware function from left to right. The result is a prepared store that can be passed to a framework adapter. +The root `pipe` object exposes only `.use()`. Each `.use()` returns a builder that can register another middleware or call `.create(initialState)`. `.create()` creates a new `Store`, applies the registered middleware in declaration order, and returns the prepared store for a framework adapter. ## Basic usage @@ -20,51 +22,49 @@ import { create } from '@ilokesto/state/react'; import { logger, persist } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const counterStore = pipe( - { count: 0 }, - persist({ local: 'counter' }), - logger({ collapsed: true, diff: true }), -); +type CounterState = { count: number }; -export const useCounter = create(counterStore); -``` +const decodeCounter = (value: unknown): CounterState | null => { + if (typeof value !== 'object' || value === null) return null; + if (!('count' in value) || typeof value.count !== 'number') return null; + return { count: value.count }; +}; -The middleware order above means persistence prepares the initial value, then logger observes later updates. Middleware order matters because each middleware receives and returns the store. +const counterStore = pipe + .use(persist({ local: 'counter', decode: decodeCounter })) + .use(logger({ collapsed: true, diff: true })) + .create({ count: 0 }); -## Why not just call middleware manually? +export const useCounter = create(counterStore); +``` -You can always compose middleware by hand: +Middleware setup runs from the first `.use()` to the last. During updates, the first registered middleware is outermost. Order matters, and `pipe` validates the declared order without rearranging it. -```ts lineNumbers -const store = logger({ collapsed: true })(persist({ local: 'counter' })({ count: 0 })); -``` +## Why builder syntax? -`pipe` makes the same flow easier to scan and keeps the order visually aligned with runtime order. +`pipe` is not callable and does not accept a variadic middleware list. Register one middleware per `.use()`, then create the store from plain initial state. ```ts lineNumbers -const store = pipe( - { count: 0 }, - persist({ local: 'counter' }), - logger({ collapsed: true }), -); +const store = pipe + .use(validate(counterSchema)) + .use(logger({ collapsed: true })) + .create({ count: 0 }); ``` -Read the list from top to bottom: create state, apply persistence, apply logging. +Read the chain from top to bottom: register validation, register logging, then create state. ## Use with validation Validation is often best placed before middleware that performs side effects, so invalid states do not get persisted or sent to tooling. ```ts lineNumbers -import { devtools, persist, validate } from '@ilokesto/state/middleware'; +import { devtools, validate } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const settingsStore = pipe( - { theme: 'system' as 'system' | 'light' | 'dark' }, - validate(settingsSchema), - persist({ local: 'settings' }), - devtools('settings'), -); +const settingsStore = pipe + .use(validate(settingsSchema)) + .use(devtools('settings')) + .create({ theme: 'system' as 'system' | 'light' | 'dark' }); ``` If validation rejects an update, later middleware in the chain should not see the invalid state. @@ -78,10 +78,9 @@ import { create } from '@ilokesto/state/react'; import { logger } from '@ilokesto/state/middleware'; import { pipe } from '@ilokesto/state/utils'; -const todoStore = pipe( - { items: [] as string[] }, - logger({ diff: true }), -); +const todoStore = pipe + .use(logger({ diff: true })) + .create({ items: [] as string[] }); export const useTodos = create(reduceTodos, todoStore); ``` @@ -90,27 +89,26 @@ Reducer actions are converted to next state, then the store middleware pipeline ## Existing Store instances -`pipe` always creates a new `Store` from the initial state. If you already own a specific `Store` instance and need to preserve that exact instance, pass the store to middleware helpers directly instead of using `pipe`. +`pipe` always creates a new `Store` from plain initial state. `.create()` rejects an existing `Store`. If another module already owns the exact store instance, pass that store directly to the framework adapter and do not use `pipe` for its construction. ```ts lineNumbers import { Store } from '@ilokesto/store'; -import { logger } from '@ilokesto/state/middleware'; +import { create } from '@ilokesto/state/react'; const existingStore = new Store({ count: 0 }); -const storeWithLogger = logger({ collapsed: true })(existingStore); +const useCounter = create(existingStore); ``` -Use this style when another module already subscribes to the store or when the store is created by infrastructure outside the utility layer. +Use this style when another module already subscribes to the store or when infrastructure outside the utility layer created it. ## Testing a piped store Because `pipe` returns a plain `Store`, it can be tested before any framework adapter is involved. ```ts lineNumbers -const store = pipe( - { count: 0 }, - validate(counterSchema), -); +const store = pipe + .use(validate(counterSchema)) + .create({ count: 0 }); store.setState({ count: 1 }); expect(store.getState()).toEqual({ count: 1 }); @@ -120,7 +118,8 @@ For persistence or browser-only middleware, run tests in an environment that pro ## Common mistakes -- **Assuming `pipe` mutates an existing store.** It creates a new `Store` from `initialState`. -- **Ignoring order.** Middleware is applied left-to-right, and side-effect middleware should usually come after validation. +- **Calling `pipe(initialState, ...middleware)`.** `pipe` is an object; start with `pipe.use(...)` and finish with `.create(initialState)`. +- **Passing an existing store to `.create()`.** The builder accepts plain initial state only and creates a new `Store`. +- **Ignoring order.** Middleware is registered in declaration order, and side-effect middleware should usually come after validation. - **Putting framework adapter calls inside `pipe`.** `pipe` composes store middleware, not React/Vue/Svelte/Solid/Angular adapter calls. - **Using `pipe` for one-off direct updates.** Use `setState`, `dispatch`, or [`adaptor`](/en/state/utility/adaptor) for updates after the store exists.