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/core-concepts.ko.mdx b/content/docs/state/core-concepts.ko.mdx index b717da5..8166020 100644 --- a/content/docs/state/core-concepts.ko.mdx +++ b/content/docs/state/core-concepts.ko.mdx @@ -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 diff --git a/content/docs/state/core-concepts.mdx b/content/docs/state/core-concepts.mdx index 04111e5..e2a01fe 100644 --- a/content/docs/state/core-concepts.mdx +++ b/content/docs/state/core-concepts.mdx @@ -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 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..e068ca8 100644 --- a/content/docs/state/middleware/debounce.ko.mdx +++ b/content/docs/state/middleware/debounce.ko.mdx @@ -10,26 +10,55 @@ 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끼리 조합됩니다. ## Timing 주의점 -timer가 실행되기 전까지 `getState()`는 이전에 commit된 state를 반환합니다. 모든 중간 write가 즉시 관찰되어야 하는 state에는 `debounce`를 쓰지 마세요. +timer가 실행되기 전까지 `getState()`는 이전에 commit된 state를 반환합니다. 모든 중간 write가 즉시 관찰되어야 하는 state에는 `debounce`를 쓰지 마세요. `history()`는 동기 commit이 필요하므로 이 pipe chain에 함께 쓸 수 없습니다. + +## Cleanup + +`debounce`는 대기 중인 timer를 소유합니다. Store가 더 이상 필요 없으면 `@ilokesto/state/middleware`의 `dispose(store)`를 호출해 취소하세요. Disposal은 해당 store에만 적용되며 반복 호출해도 안전합니다. Cleanup 하나가 실패해도 등록된 모든 cleanup을 시도하며, 하나라도 실패하면 각 원래 thrown value가 담긴 `AggregateError`를 throw합니다. diff --git a/content/docs/state/middleware/debounce.mdx b/content/docs/state/middleware/debounce.mdx index 9defc08..a029d2c 100644 --- a/content/docs/state/middleware/debounce.mdx +++ b/content/docs/state/middleware/debounce.mdx @@ -10,26 +10,55 @@ 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. ## Timing caveat -Until the timer fires, `getState()` still returns the previous committed state. Do not use `debounce` for state where every intermediate write must be immediately observable. +Until the timer fires, `getState()` still returns the previous committed state. Do not use `debounce` for state where every intermediate write must be immediately observable. `history()` cannot share this pipe chain because history requires synchronous commits. + +## Cleanup + +`debounce` owns its pending timer. When the store is no longer needed, call `dispose(store)` from `@ilokesto/state/middleware` to cancel it. Disposal is scoped to that store and is safe to repeat. It still attempts every registered cleanup after a failure, then throws an `AggregateError` containing each original thrown value when any cleanup fails. 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/history.ko.mdx b/content/docs/state/middleware/history.ko.mdx new file mode 100644 index 0000000..66d76e0 --- /dev/null +++ b/content/docs/state/middleware/history.ko.mdx @@ -0,0 +1,50 @@ +--- +title: "history" +description: "piped store에 동기 undo와 redo control을 추가합니다." +--- + +# history + +`history`는 성공한 동기 state 변경을 기록하고 준비된 store에 undo/redo control을 추가합니다. + +## Signature + +```ts lineNumbers +history(options?: HistoryOptions): PipeAnyMiddleware + +type HistoryOptions = { + readonly limit?: number; +}; +``` + +`limit`은 undo entry의 최대 개수이며 기본값은 `300`입니다. 유한한 0 이상의 정수여야 합니다. Control이 있는 store를 받는 API에는 `HistoryStore`를, control만 받는 API에는 `HistoryControls`를 사용하세요. + +## 예제 + + +```ts +import { history } from '@ilokesto/state/middleware'; +import type { HistoryStore } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +const store: HistoryStore<{ count: number }> = pipe + .use(history({ limit: 20 })) + .create({ count: 0 }); + +store.setState({ count: 1 }); +store.undo(); +store.redo(); +store.clearHistory(); +``` + +`undo()`와 `redo()`는 기록한 state를 store에 적용하므로 호환되는 middleware는 replay를 관찰할 수 있습니다. Replay된 state는 다시 기록하지 않습니다. `canUndo()`와 `canRedo()`는 가능한 replay가 있는지 보고하고, `clearHistory()`는 current state를 바꾸지 않고 entry를 제거합니다. + +## 조합 제약 + +History에는 동기 commit 경계가 필요합니다. 선언 순서와 관계없이 `debounce()` 또는 `throttle()`과 같은 pipe chain에 둘 수 없습니다. 이 조합은 `MIDDLEWARE_CONFLICT` code의 `PipeConfigurationError`를 던지며, pipe는 chain을 유효하게 만들기 위해 middleware 순서를 바꾸지 않습니다. + +History middleware 자체는 timer나 subscription을 소유하지 않습니다. 같은 store가 cleanup을 등록하는 middleware도 사용한다면 해당 store가 더 이상 필요 없을 때 `dispose(store)`를 호출하세요. 소유권과 idempotency는 [Middleware](/ko/state/middleware)를 참고하세요. + +## Control 충돌 + +History는 `undo`, `redo`, `canUndo`, `canRedo`, `clearHistory`를 변경할 수 없는 store property로 추가합니다. Store에 이 property 중 하나가 이미 있으면 control을 설치하기 전에 `CONTROL_COLLISION` code의 `HistoryConfigurationError`를 던집니다. diff --git a/content/docs/state/middleware/history.mdx b/content/docs/state/middleware/history.mdx new file mode 100644 index 0000000..c5df0fe --- /dev/null +++ b/content/docs/state/middleware/history.mdx @@ -0,0 +1,50 @@ +--- +title: "history" +description: "Add synchronous undo and redo controls to a piped store." +--- + +# history + +`history` records successful synchronous state changes and adds undo/redo controls to the prepared store. + +## Signature + +```ts lineNumbers +history(options?: HistoryOptions): PipeAnyMiddleware + +type HistoryOptions = { + readonly limit?: number; +}; +``` + +`limit` is the maximum number of undo entries and defaults to `300`. It must be a finite non-negative integer. Use `HistoryStore` when an API accepts a store with the controls, and `HistoryControls` when it accepts only the controls. + +## Example + + +```ts +import { history } from '@ilokesto/state/middleware'; +import type { HistoryStore } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +const store: HistoryStore<{ count: number }> = pipe + .use(history({ limit: 20 })) + .create({ count: 0 }); + +store.setState({ count: 1 }); +store.undo(); +store.redo(); +store.clearHistory(); +``` + +`undo()` and `redo()` apply the recorded state through the store, so compatible middleware can observe the replay. Replayed states are not recorded again. `canUndo()` and `canRedo()` report whether a replay is available; `clearHistory()` removes entries without changing current state. + +## Composition constraints + +History needs a synchronous commit boundary. It cannot share a pipe chain with `debounce()` or `throttle()` in either declaration order. Those combinations throw `PipeConfigurationError` with code `MIDDLEWARE_CONFLICT`; pipe does not reorder middleware to make the chain valid. + +History middleware itself does not own timers or subscriptions. If the same store also uses middleware that registers cleanup, call `dispose(store)` when that store is no longer needed. See [Middleware](/en/state/middleware) for ownership and idempotency details. + +## Control collisions + +History adds `undo`, `redo`, `canUndo`, `canRedo`, and `clearHistory` as immutable store properties. If the store already has one of those properties, setup throws `HistoryConfigurationError` with code `CONTROL_COLLISION` before installing controls. diff --git a/content/docs/state/middleware/index.ko.mdx b/content/docs/state/middleware/index.ko.mdx index ba602a7..7720e45 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 }); ``` ## 제공되는 미들웨어 @@ -47,9 +50,20 @@ const store = pipe( | [`logger`](/ko/state/middleware/logger) | 개발 중 state update 로그 | production에서는 비활성화되며 diff는 debug output입니다. | | [`validate`](/ko/state/middleware/validate) | synchronous Standard Schema validation | async schema 결과는 거부됩니다. | | [`debounce`](/ko/state/middleware/debounce) | 빠른 update 지연 및 병합 | timer 전 read는 이전 state를 볼 수 있습니다. | -| [`devtools`](/ko/state/middleware/devtools) | Redux DevTools निरीpection | browser extension에 의존하며 production에서는 비활성화됩니다. | +| [`history`](/ko/state/middleware/history) | 동기 변경의 undo와 redo | debounce 또는 throttle과 같은 체인을 쓸 수 없습니다. | +| [`throttle`](/ko/state/middleware/throttle) | leading-edge rate limiting | wait window 중 update는 드롭됩니다. | +| [`devtools`](/ko/state/middleware/devtools) | Redux DevTools inspection | browser extension에 의존하며 production에서는 비활성화됩니다. | | [`persist`](/ko/state/middleware/persist) | browser storage persistence | storage type마다 migration 지원이 다릅니다. | +| `dispose(store)` | middleware 소유 resource 해제 | 해당 store가 더 이상 필요 없을 때 호출합니다. | ## 순서 잡는 법 -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`로 거부됩니다. + +`history()`는 성공한 동기 commit만 기록하므로 선언 순서와 관계없이 `debounce()` 또는 `throttle()`과 같은 pipe 체인을 쓸 수 없습니다. 이 조합은 `MIDDLEWARE_CONFLICT`로 거부되며 pipe가 유효하게 만들려고 chain을 재정렬하지 않습니다. + +## Store 정리 + +Timer 기반 middleware와 외부 subscription을 가진 middleware는 자신이 준비한 store에 cleanup을 등록합니다. 해당 store가 더 이상 필요 없으면 `@ilokesto/state/middleware`의 `dispose(store)`를 호출하세요. 대기 중인 debounce/throttle 작업을 취소하고 DevTools subscription 같은 middleware 소유 resource를 해제합니다. disposal은 consumer를 unsubscribe하거나 Store를 무효화하지 않습니다. 전달한 store에만 적용되고 여러 번 안전하게 호출할 수 있으며, 나중에 새 cleanup이 등록된 경우에만 이후 호출에서 실행합니다. + +Disposal은 cleanup 하나가 throw해도 등록된 모든 cleanup을 시도합니다. 하나 이상의 cleanup이 throw하면 `dispose`는 원래 error 또는 다른 thrown value가 `errors`에 담긴 `AggregateError`를 다시 throw합니다. diff --git a/content/docs/state/middleware/index.mdx b/content/docs/state/middleware/index.mdx index d241a4f..931aec9 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 @@ -47,9 +50,20 @@ const store = pipe( | [`logger`](/en/state/middleware/logger) | Development-time state update logs | Disabled in production; diff is debug output. | | [`validate`](/en/state/middleware/validate) | Synchronous Standard Schema validation | Async schema results are rejected. | | [`debounce`](/en/state/middleware/debounce) | Delaying and coalescing rapid updates | Reads before the timer fires still see previous state. | +| [`history`](/en/state/middleware/history) | Undo and redo for synchronous changes | Cannot share a chain with debounce or throttle. | +| [`throttle`](/en/state/middleware/throttle) | Leading-edge rate limiting | Updates during the wait window are dropped. | | [`devtools`](/en/state/middleware/devtools) | Redux DevTools inspection | Browser extension dependent; disabled in production. | | [`persist`](/en/state/middleware/persist) | Browser storage persistence | Migration support differs by storage type. | +| `dispose(store)` | Release middleware-owned resources | Call it when the store is no longer needed. | ## 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`. + +`history()` records only successful synchronous commits, so it cannot share a pipe chain with `debounce()` or `throttle()` in either declaration order. Those combinations are rejected with `MIDDLEWARE_CONFLICT`; pipe never reorders a chain to make it valid. + +## Store disposal + +Timer-based middleware and middleware with external subscriptions register cleanup on the store they prepare. Call `dispose(store)` from `@ilokesto/state/middleware` when that specific store is no longer needed. It cancels pending debounce or throttle work and releases middleware-owned resources such as DevTools subscriptions. Disposal does not unsubscribe consumers or invalidate the Store; it is scoped to the supplied store, is safe to call repeatedly, and runs a newly registered cleanup only on a later call. + +Disposal attempts every registered cleanup even when one throws. If one or more cleanups throw, `dispose` rethrows an `AggregateError` whose `errors` contain the original errors or other thrown values. 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/meta.json b/content/docs/state/middleware/meta.json index a34f142..985795d 100644 --- a/content/docs/state/middleware/meta.json +++ b/content/docs/state/middleware/meta.json @@ -6,6 +6,8 @@ "logger", "validate", "debounce", + "history", + "throttle", "devtools", "persist" ] diff --git a/content/docs/state/middleware/persist.ko.mdx b/content/docs/state/middleware/persist.ko.mdx index 09a5c4b..bc882ed 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를 건너뛰므로 `MIDDLEWARE_ORDER`로 거부됩니다. Pipe는 선언 순서를 유지하며 자동으로 재정렬하지 않습니다. + ## 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..9638de2 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 with `MIDDLEWARE_ORDER` because it would skip persistence for deferred commits. Pipe keeps the declared order and never rearranges it. + ## 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/throttle.ko.mdx b/content/docs/state/middleware/throttle.ko.mdx new file mode 100644 index 0000000..cf5071d --- /dev/null +++ b/content/docs/state/middleware/throttle.ko.mdx @@ -0,0 +1,41 @@ +--- +title: "throttle" +description: "선행 update를 통과시키고 throttle window가 끝날 때까지 뒤의 update를 드롭합니다." +--- + +# throttle + +`throttle`은 leading-drop 방식으로 동작합니다. 첫 번째 update는 즉시 통과하고, 이후 update는 wait window가 끝날 때까지 드롭됩니다. 드롭된 update는 대기열에 넣거나 다시 시도하지 않습니다. + +## Signature + +```ts lineNumbers +throttle(wait?: number): PipeAnyMiddleware +``` + +`wait`의 단위는 밀리초이며 기본값은 `300`입니다. 전달했다면 유한한 0 이상의 숫자여야 합니다. + +## 예제 + + +```ts +import { dispose, throttle } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +const store = pipe + .use(throttle(250)) + .create({ count: 0 }); + +store.setState({ count: 1 }); +store.setState({ count: 2 }); // wait window 중 드롭 + +dispose(store); +``` + +## Cleanup 소유권 + +`throttle`은 자신이 준비한 store의 pending timer를 소유합니다. 해당 store가 더 이상 필요 없으면 `dispose(store)`를 호출해 timer를 취소하고 gate를 다시 여세요. Disposal은 전달한 store에만 적용되고 store를 무효화하거나 consumer를 unsubscribe하지 않으며 반복 호출해도 안전합니다. Cleanup 하나가 실패해도 등록된 모든 cleanup을 시도하며, 하나라도 실패하면 각 원래 thrown value가 담긴 `AggregateError`를 throw합니다. + +## 조합 제약 + +`history()`는 성공한 동기 commit만 기록하므로 `throttle()`과 같은 chain에 둘 수 없습니다. 선언 순서와 관계없이 `MIDDLEWARE_CONFLICT` code의 `PipeConfigurationError`로 거부되며, pipe는 middleware 순서를 바꾸지 않고 선언한 순서를 유지합니다. diff --git a/content/docs/state/middleware/throttle.mdx b/content/docs/state/middleware/throttle.mdx new file mode 100644 index 0000000..d097274 --- /dev/null +++ b/content/docs/state/middleware/throttle.mdx @@ -0,0 +1,41 @@ +--- +title: "throttle" +description: "Pass the leading update and drop updates until the throttle window ends." +--- + +# throttle + +`throttle` uses leading-drop behavior: the first update passes through immediately, then later updates are dropped until the wait window ends. Dropped updates are not queued or retried. + +## Signature + +```ts lineNumbers +throttle(wait?: number): PipeAnyMiddleware +``` + +`wait` is measured in milliseconds and defaults to `300`. When supplied, it must be a finite non-negative number. + +## Example + + +```ts +import { dispose, throttle } from '@ilokesto/state/middleware'; +import { pipe } from '@ilokesto/state/utils'; + +const store = pipe + .use(throttle(250)) + .create({ count: 0 }); + +store.setState({ count: 1 }); +store.setState({ count: 2 }); // dropped during the wait window + +dispose(store); +``` + +## Cleanup ownership + +`throttle` owns the pending timer for the store it prepares. Call `dispose(store)` when that specific store is no longer needed to cancel the timer and reopen the gate. Disposal is scoped to the supplied store, does not invalidate it or unsubscribe consumers, and is safe to call repeatedly. It still attempts every registered cleanup after a failure, then throws an `AggregateError` containing each original thrown value when any cleanup fails. + +## Composition constraints + +`history()` cannot share a chain with `throttle()` because history records only successful synchronous commits. Either declaration order is rejected with `PipeConfigurationError` code `MIDDLEWARE_CONFLICT`; pipe preserves the declared order instead of rearranging middleware. 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..fb6b6f8 100644 --- a/content/docs/state/reference/package-surface.ko.mdx +++ b/content/docs/state/reference/package-surface.ko.mdx @@ -12,13 +12,27 @@ 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/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/react` | React `create()` adapter와 `UseState`, `UseReducer` type입니다. | +| `@ilokesto/state/vue` | Vue `create()` adapter와 `UseState`, `UseReducer` type입니다. | +| `@ilokesto/state/svelte` | Svelte `create()` adapter와 `UseState`, `UseReducer` type입니다. | +| `@ilokesto/state/solid` | Solid `create()` adapter와 `UseState`, `UseReducer` type입니다. | +| `@ilokesto/state/angular` | Angular `create()` adapter와 `AngularOptions`, `UseState`, `UseReducer` type입니다. | +| `@ilokesto/state/middleware` | 등록된 middleware, store disposal, middleware type입니다. | +| `@ilokesto/state/utils` | pipe builder, custom middleware 등록, configuration error, pipe type입니다. | +| `@ilokesto/state/adaptor` | `adaptor`; optional `immer` peer가 필요합니다. | +| `@ilokesto/state/package.json` | 이 패키지 manifest가 필요한 tooling을 위한 package metadata export입니다. | + +## Middleware export + +`@ilokesto/state/middleware`는 `debounce`, `throttle`, `devtools`, `dispose`, `history`, `HistoryConfigurationError`, `logger`, `persist`, `validate`를 export합니다. + +Public type은 `HistoryControls`, `HistoryOptions`, `HistoryStore`, `OnRehydrateStorage`, `OnRehydrateStorageCallback`, `PersistControls`, `PersistDecoder`, `PersistDecoderStateDiagnostic`, `PersistMigration`, `PersistStore`, `SafePersistConfig`, `SafePersistCookieConfig`, `SafePersistLocalConfig`, `SafePersistSessionConfig`입니다. + +## Utility export + +`@ilokesto/state/utils`는 `pipe`, `definePipeableMiddleware`, `PipeConfigurationError`를 export합니다. + +Public type은 `Pipe`, `PipeAnyMiddleware`, `PipeBuilder`, `PipeCapability`, `PipeConfigurationErrorCode`, `PipeDuplicatePolicy`, `PipeMiddleware`, `PipeMiddlewareConflictDiagnostic`, `PipeMiddlewareMetadata`입니다. ## Root entrypoint 주의점 @@ -35,4 +49,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..1f59794 100644 --- a/content/docs/state/reference/package-surface.mdx +++ b/content/docs/state/reference/package-surface.mdx @@ -12,13 +12,27 @@ 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/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/react` | React `create()` adapter and `UseState`, `UseReducer` types. | +| `@ilokesto/state/vue` | Vue `create()` adapter and `UseState`, `UseReducer` types. | +| `@ilokesto/state/svelte` | Svelte `create()` adapter and `UseState`, `UseReducer` types. | +| `@ilokesto/state/solid` | Solid `create()` adapter and `UseState`, `UseReducer` types. | +| `@ilokesto/state/angular` | Angular `create()` adapter and `AngularOptions`, `UseState`, `UseReducer` types. | +| `@ilokesto/state/middleware` | Registered middleware, store disposal, and middleware types. | +| `@ilokesto/state/utils` | The pipe builder, custom-middleware registration, configuration errors, and pipe types. | +| `@ilokesto/state/adaptor` | `adaptor`; requires the optional `immer` peer. | +| `@ilokesto/state/package.json` | Package metadata export for tooling that needs this package manifest. | + +## Middleware exports + +`@ilokesto/state/middleware` exports `debounce`, `throttle`, `devtools`, `dispose`, `history`, `HistoryConfigurationError`, `logger`, `persist`, and `validate`. + +Its public types are `HistoryControls`, `HistoryOptions`, `HistoryStore`, `OnRehydrateStorage`, `OnRehydrateStorageCallback`, `PersistControls`, `PersistDecoder`, `PersistDecoderStateDiagnostic`, `PersistMigration`, `PersistStore`, `SafePersistConfig`, `SafePersistCookieConfig`, `SafePersistLocalConfig`, and `SafePersistSessionConfig`. + +## Utility exports + +`@ilokesto/state/utils` exports `pipe`, `definePipeableMiddleware`, and `PipeConfigurationError`. + +Its public types are `Pipe`, `PipeAnyMiddleware`, `PipeBuilder`, `PipeCapability`, `PipeConfigurationErrorCode`, `PipeDuplicatePolicy`, `PipeMiddleware`, `PipeMiddlewareConflictDiagnostic`, and `PipeMiddlewareMetadata`. ## Root entrypoint caveat @@ -35,4 +49,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..c06ebda 100644 --- a/content/docs/state/utility/index.ko.mdx +++ b/content/docs/state/utility/index.ko.mdx @@ -5,9 +5,11 @@ description: "pipe로 middleware를 조합하고 adaptor로 Immer 스타일 obje # 유틸리티 소개 -`@ilokesto/state/utils`는 framework adapter에 묶이지 않는 작은 helper를 export합니다. +`@ilokesto/state/utils`는 framework adapter에 묶이지 않는 pipe helper를 export합니다. 별도 `@ilokesto/state/adaptor` subpath는 Immer 기반 update helper를 export합니다. -- [`pipe`](/ko/state/utility/pipe)는 `@ilokesto/store` instance를 만들고 middleware를 순서대로 적용합니다. +- [`pipe`](/ko/state/utility/pipe)는 `@ilokesto/store` instance를 만들고 등록된 middleware를 순서대로 적용합니다. +- [`definePipeableMiddleware`](/ko/state/utility/pipe#custom-middleware)는 `pipe.use(...)`에 쓸 custom middleware metadata를 등록합니다. +- [`PipeConfigurationError`](/ko/state/utility/pipe#configuration-errors)는 잘못된 runtime pipe configuration을 보고합니다. - [`adaptor`](/ko/state/utility/adaptor)는 Immer `produce`를 감싸 object update를 draft mutation syntax로 쓸 수 있게 합니다. React, Vue, Svelte, Solid, Angular에 연결하기 전에 store 자체를 준비할 때 Utility page를 보세요. @@ -21,11 +23,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); ``` @@ -46,7 +56,8 @@ pnpm add immer | Utility | 사용할 때 | 피할 때 | | --- | --- | --- | -| [`pipe`](/ko/state/utility/pipe) | store를 만들고 middleware를 왼쪽에서 오른쪽으로 적용하고 싶을 때 | 이미 소유한 `Store` instance를 직접 조작해야 할 때 | +| [`pipe`](/ko/state/utility/pipe) | store를 만들고 등록된 middleware를 왼쪽에서 오른쪽으로 적용하고 싶을 때 | 이미 소유한 `Store` instance를 직접 조작해야 할 때 | +| [`definePipeableMiddleware`](/ko/state/utility/pipe#custom-middleware) | pipe chain에 custom middleware를 노출할 때 | middleware를 `pipe` 밖에서만 사용할 때 | | [`adaptor`](/ko/state/utility/adaptor) | object update가 draft mutation으로 더 읽기 쉬울 때 | state가 primitive이거나 `immer`를 설치하고 싶지 않을 때 | ## 일반적인 composition flow @@ -59,12 +70,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..e633e56 100644 --- a/content/docs/state/utility/index.mdx +++ b/content/docs/state/utility/index.mdx @@ -5,9 +5,11 @@ description: "Compose middleware with pipe and write Immer-style object updates # Utility -`@ilokesto/state/utils` exports small helpers for the parts of state management that are not tied to a framework adapter: +`@ilokesto/state/utils` exports the pipe helpers that are not tied to a framework adapter. The separate `@ilokesto/state/adaptor` subpath exports the Immer-backed update helper: -- [`pipe`](/en/state/utility/pipe) creates an `@ilokesto/store` instance and applies middleware in order. +- [`pipe`](/en/state/utility/pipe) creates an `@ilokesto/store` instance and applies registered middleware in order. +- [`definePipeableMiddleware`](/en/state/utility/pipe#custom-middleware) registers custom middleware metadata for `pipe.use(...)`. +- [`PipeConfigurationError`](/en/state/utility/pipe#configuration-errors) reports invalid runtime pipe configurations. - [`adaptor`](/en/state/utility/adaptor) wraps Immer `produce` so object updates can be written with draft mutation syntax. Use Utility pages when you are building the store before connecting it to React, Vue, Svelte, Solid, or Angular. @@ -21,11 +23,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); ``` @@ -46,7 +56,8 @@ pnpm add immer | Utility | Use it when | Avoid it when | | --- | --- | --- | -| [`pipe`](/en/state/utility/pipe) | You want to create a store and apply middleware left-to-right. | You already own a `Store` and want to mutate that exact instance manually. | +| [`pipe`](/en/state/utility/pipe) | You want to create a store and apply registered middleware left-to-right. | You already own a `Store` and want to mutate that exact instance manually. | +| [`definePipeableMiddleware`](/en/state/utility/pipe#custom-middleware) | You are exposing custom middleware to a pipe chain. | The middleware is only used outside `pipe`. | | [`adaptor`](/en/state/utility/adaptor) | Object updates are easier to express as draft mutations. | State is primitive, or you do not want to install `immer`. | ## Typical composition flow @@ -59,12 +70,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..ce4ad2d 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,65 @@ 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 }; + +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 })) + .use(logger({ collapsed: true, diff: true })) + .create({ count: 0 }); export const useCounter = create(counterStore); ``` -위 순서는 persistence가 initial value를 준비하고, 그 다음 logger가 이후 update를 관찰한다는 뜻입니다. 각 middleware가 store를 받아 다시 반환하므로 순서가 중요합니다. +Middleware setup은 첫 번째 `.use()`부터 마지막 `.use()`까지 실행됩니다. Update에서는 처음 등록한 middleware가 가장 바깥쪽입니다. 순서가 중요하며 `pipe`는 선언 순서를 바꾸지 않고 검증합니다. -## Middleware를 직접 호출하면 안 되나? +## Public pipe type -직접 조합할 수도 있습니다. +`@ilokesto/state/utils`는 root builder용 `Pipe`와 구성된 chain용 `PipeBuilder`를 export합니다. `PipeMiddleware`는 state-specific middleware를, `PipeAnyMiddleware`는 모든 state type에 동작하는 middleware를 나타냅니다. `PipeCapability`는 middleware가 추가한 store API를, `PipeMiddlewareMetadata`는 ID와 관계를, `PipeDuplicatePolicy`는 duplicate ID 정책을 설명합니다. -```ts lineNumbers -const store = logger({ collapsed: true })(persist({ local: 'counter' })({ count: 0 })); -``` +`PipeMiddlewareConflictDiagnostic`은 선언한 metadata가 충돌할 때 노출되는 compile-time diagnostic입니다. Runtime의 잘못된 configuration은 `PipeConfigurationError`를 던지며, `code`의 type은 `PipeConfigurationErrorCode`입니다. + +## 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 생성. + +## Custom middleware + +`.use()`에 전달하는 모든 값은 `definePipeableMiddleware()`로 등록되어야 합니다. 각 middleware에 `id`를 주고 metadata로 `adds`, `requires`, `before`, `after`, `conflicts`, optional `duplicate` policy를 선언하세요. 앞선 바깥 middleware가 추가한 capability는 이후 안쪽 middleware에서 사용할 수 있습니다. Chain에 없는 middleware와의 관계는 무시합니다. 존재하는 `before` 또는 `after` 관계에서는 pipe가 선언한 방향을 검증하고, 위반된(반대) 순서만 거부합니다. 유효한 관계는 선언한 순서를 유지합니다. Conflict, duplicate ID, cycle도 재정렬 없이 거부됩니다. + +## Configuration errors + +`PipeConfigurationError`는 문맥에 따른 기본 `id`, 관련 `ids`, 그리고 `DUPLICATE_CAPABILITY`, `DUPLICATE_MIDDLEWARE`, `INVALID_METADATA`, `INVALID_MIDDLEWARE_RESULT`, `INVALID_STORE_INPUT`, `MISSING_CAPABILITY`, `MIDDLEWARE_CONFLICT`, `MIDDLEWARE_CYCLE`, `MIDDLEWARE_ORDER` 중 하나인 code를 노출합니다. 기본 ID는 middleware나 capability를 가리킬 수 있고 적용할 identifier가 없으면 비어 있습니다. `ids`에는 관련된 문맥 identifier가 들어가며 비어 있을 수도 있습니다. + +`history()`는 동기 commit이 필요하므로 어느 순서든 `debounce()` 또는 `throttle()`과 같은 chain에 둘 수 없고 이 조합은 `MIDDLEWARE_CONFLICT`를 사용합니다. `debounce()`와 `persist()`를 함께 쓸 때는 `pipe.use(debounce(...)).use(persist(...))`처럼 debounce를 먼저 선언하세요. 반대 순서는 persistence가 deferred commit을 놓치므로 `MIDDLEWARE_ORDER`를 사용합니다. ## 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 +94,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 +105,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 +134,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..da424ed 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,65 @@ 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 }; + +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 })) + .use(logger({ collapsed: true, diff: true })) + .create({ count: 0 }); export const useCounter = create(counterStore); ``` -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. +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. -## Why not just call middleware manually? +## Public pipe types -You can always compose middleware by hand: +`@ilokesto/state/utils` exports `Pipe` for the root builder and `PipeBuilder` for a configured chain. `PipeMiddleware` describes state-specific middleware; `PipeAnyMiddleware` describes middleware that works for every state type. `PipeCapability` describes store API added by middleware, `PipeMiddlewareMetadata` declares its ID and relationships, and `PipeDuplicatePolicy` controls duplicate IDs. -```ts lineNumbers -const store = logger({ collapsed: true })(persist({ local: 'counter' })({ count: 0 })); -``` +`PipeMiddlewareConflictDiagnostic` is the compile-time diagnostic exposed when declared metadata conflicts. At runtime, invalid configuration throws `PipeConfigurationError`; its `code` is `PipeConfigurationErrorCode`. + +## 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. + +## Custom middleware + +Every value passed to `.use()` must be registered with `definePipeableMiddleware()`. Give each middleware an `id`; use metadata to declare `adds`, `requires`, `before`, `after`, `conflicts`, and the optional `duplicate` policy. Capabilities added by an earlier outer middleware are available to later inner middleware. A relation to middleware absent from the chain is ignored. For a present `before` or `after` relation, pipe validates the declared direction and rejects only violated (reversed) order; valid relations keep their declared order. Conflicts, duplicate IDs, and cycles are also rejected without reordering. + +## Configuration errors + +`PipeConfigurationError` exposes a contextual primary `id`, related `ids`, and a code: `DUPLICATE_CAPABILITY`, `DUPLICATE_MIDDLEWARE`, `INVALID_METADATA`, `INVALID_MIDDLEWARE_RESULT`, `INVALID_STORE_INPUT`, `MISSING_CAPABILITY`, `MIDDLEWARE_CONFLICT`, `MIDDLEWARE_CYCLE`, or `MIDDLEWARE_ORDER`. The primary ID can name a middleware or a capability, and is empty when no identifier applies; `ids` contains the related contextual identifiers and can also be empty. + +`history()` cannot share a chain with `debounce()` or `throttle()` in either order because history needs synchronous commits; those combinations use `MIDDLEWARE_CONFLICT`. When using `debounce()` and `persist()`, declare debounce first: `pipe.use(debounce(...)).use(persist(...))`. The reverse uses `MIDDLEWARE_ORDER` because persistence would miss the deferred commit. ## 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 +94,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 +105,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 +134,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.