persist
persist는 상태 commit은 동기로 유지하면서 비동기 복원, 저장, 오류를 관리합니다. key, storage 팩토리, 필수 decode 함수를 설정하세요. pipe.use(...)에 등록한 뒤 .create(initialState)로 스토어를 만듭니다.
헬퍼, 미들웨어, 스토어 생성 시에는 저장소 I/O나 window, document, indexedDB 접근이 없습니다. await store.persist.rehydrate()로 복원을 명시적으로 시작하세요.
JSON 상태 저장과 복원
다음 완전한 예제를 브라우저 모듈에서 실행하면 이전 테마를 복원하고 어두운 테마로 변경한 뒤 저장 완료를 기다립니다.
import { dispose, jsonStorage, 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 || !('theme' in value)) return null;
return value.theme === 'light' || value.theme === 'dark' ? { theme: value.theme } : null;
};
const store = pipe
.use(persist({
key: 'theme',
storage: jsonStorage(() => window.localStorage),
decode: decodeTheme,
}))
.create<ThemeState>({ theme: 'light' });
try {
await store.persist.rehydrate();
store.setState({ theme: 'dark' });
await store.persist.flush();
} finally {
dispose(store);
}호출부에서 거부된 작업을 처리하세요. setState는 동기로 commit하지만 저장소 반영 완료를 뜻하지 않습니다.
어댑터 선택
다음 헬퍼는 모두 @ilokesto/state/middleware에서 가져오며 저장소 팩토리를 반환합니다. 바깥에 () =>를 추가하지 말고 반환값을 직접 전달하세요.
| 저장소 | storage 옵션 | 지원 값 |
|---|---|---|
| IndexedDB | indexedDBStorage({ database: 'my-app-state' }) | structured clone 가능한 값 |
| localStorage | jsonStorage(() => window.localStorage) | JSON 값 |
| sessionStorage | jsonStorage(() => window.sessionStorage) | JSON 값 |
| 쿠키 | cookieStorage({ path: '/' }) | 쿠키 크기 제한 내 JSON 값 |
jsonStorage는 브라우저 접근을 미루기 위해 getter를 받습니다. jsonStorage(window.localStorage)는 지원하지 않으며 SSR 중 브라우저 전역을 평가합니다. Web Storage 자체는 여전히 동기 I/O이며 영속화 완료와 오류 계약이 비동기입니다.
팩토리를 여러 스토어에서 재사용할 수 있습니다. 활성화마다 독립된 어댑터 인스턴스를 만들고 각 스토어의 수명 동안 재사용하며 매번 쓰기마다 생성하지 않습니다. 사용자 정의 팩토리에는 메타데이터가 필요 없습니다. 인스턴스는 getItem, setItem, removeItem, 필요하면 소유 리소스를 정리하는 dispose를 제공합니다. 팩토리 생성 실패는 재시도할 수 있으며 일반 I/O 실패로 이미 생성한 인스턴스를 교체하지 않습니다.
파일과 복합 자료형
IndexedDB는 { state, version }을 structured clone으로 저장합니다. base64 변환 없이 File의 이름, 타입, 내용과 Blob, Map, Set, Date, ArrayBuffer를 보존합니다.
import { indexedDBStorage, persist } from '@ilokesto/state/middleware';
import { pipe } from '@ilokesto/state/utils';
type EditorState = { attachment: File | null; updatedAt: Date };
const decodeEditor = (value: unknown): EditorState | null => {
if (typeof value !== 'object' || value === null) return null;
if (!('attachment' in value) || (value.attachment !== null && !(value.attachment instanceof File))) return null;
if (!('updatedAt' in value) || !(value.updatedAt instanceof Date)) return null;
return { attachment: value.attachment, updatedAt: value.updatedAt };
};
const editor = pipe
.use(persist({
key: 'editor',
storage: indexedDBStorage({ database: 'my-app-state' }),
decode: decodeEditor,
}))
.create<EditorState>({ attachment: null, updatedAt: new Date(0) });
await editor.persist.rehydrate();
editor.setState({
attachment: new File(['draft'], 'draft.txt', { type: 'text/plain' }),
updatedAt: new Date(),
});
await editor.persist.flush();이미지 미리보기의 blob: URL은 복원한 Blob으로 화면에서 다시 생성하고 필요 없을 때 해제하세요. URL 자체는 저장하지 않습니다. IndexedDB 성공은 request 성공이 아니라 transaction complete 기준입니다. 복제 불가능한 값, 용량 부족, 트랜잭션 실패는 저장을 거부합니다. localStorage로 자동 전환하지 않으며 JSON 어댑터는 이러한 네이티브 값을 보존하지 않습니다. Date.toJSON()은 문자열이 되고 Map, Set, Blob 등 지원하지 않는 값은 거부됩니다.
수명주기 제어
| 제어 | 계약 |
|---|---|
await rehydrate() | 명시적 복원. 동시 호출은 작업 공유, 성공 후 재호출은 아무 작업 없음, 실패 후 재시도 가능 |
await rehydrate({ conflict: 'keep-current' }) | 복원 충돌 시 현재 상태를 유지하고 저장 |
await rehydrate({ conflict: 'use-stored' }) | 검증한 저장값으로 현재 상태를 명시적으로 교체 |
await flush() | 호출 시점의 저장 대상 완료 대기, 실패한 대기 쓰기 재시도 |
await clearStorage() | 이전 쓰기 뒤에 이 키만 삭제하고 메모리 상태는 유지 |
getStatus() | 최신 불변 수명주기 상태 읽기 |
subscribe(listener) | 이후 상태 변경 관찰, 구독 해제 함수 반환 |
복원 완료 전 편집은 기본적으로 충돌을 일으켜 복원을 중단합니다. 호출자가 정책을 선택하기 전에는 메모리나 저장값을 덮어쓰지 않습니다. 자동 깊은 병합은 없습니다. 복원 성공 전 flush()는 확인하지 않은 저장값을 덮어쓰는 대신 거부됩니다.
쓰기는 직렬 실행됩니다. 하나의 쓰기가 실행되는 동안 대기 슬롯에는 최신 commit만 유지합니다. 실패한 쓰기는 대기 상태로 남고 새 commit이나 명시적 flush()로 재시도하며 무한 자동 재시도는 없습니다. flush()는 미래 편집을 끝없이 기다리거나 상태 debounce 타이머를 강제로 실행하지 않습니다. 성공한 clearStorage() 뒤에는 이전 대기 쓰기가 값을 되살리지 않으며 이후 사용자 commit은 다시 저장할 수 있습니다. 삭제로 대체된 대기 중 flush()는 CLEARED로 거부됩니다.
상태와 오류 관찰
getStatus()와 subscribe()가 hasHydrated()와 onRehydrateStorage를 대체합니다. 복원 상태는 idle, hydrating, hydrated, conflict, error이며 저장 상태는 idle, writing, error입니다. pending은 미저장 작업, disposed는 영속화 종료 상태, error는 PersistError 또는 null입니다.
다음 코드는 앞의 editor 예제에 이어 사용합니다.
const unsubscribe = editor.persist.subscribe((status) => {
if (status.error) {
console.error(status.error.operation, status.error.code, status.error.cause);
}
});
const status = editor.persist.getStatus();
const ready = status.hydration === 'hydrated';
unsubscribe();오류는 operation, 안정적인 code, 원래의 cause를 제공합니다. 읽기, migration, decode, 검증 실패는 현재 상태를 유지합니다. commit 후 알림 실패는 commit을 롤백하거나 복원을 다시 적용할 이유가 되지 않습니다.
SSR과 소유권
서버와 클라이언트에서 동일한 초기 상태를 사용하세요. 클라이언트 effect에서 rehydrate()를 시작하고 거부된 Promise를 처리합니다. 충돌 해결을 제공하지 않는 화면은 getStatus().hydration === 'hydrated'가 될 때까지 편집을 막으세요. 모든 전이를 관찰하도록 복원 시작 전에 구독합니다. skipHydration 옵션은 필요 없습니다.
스토어 소유자는 인스턴스가 더 필요 없을 때 dispose(store)를 호출합니다. 대기 작업을 취소하고 자신이 소유한 연결, 트랜잭션, 구독만 정리합니다. 두 스토어가 같은 팩토리를 사용해도 이 소유권은 공유하지 않습니다. 늦은 완료가 종료된 수명주기를 변경하지 못합니다. 정리는 저장 완료를 보장하거나 완료된 트랜잭션을 되돌리지 않습니다. 저장 보장이 필요하면 정리 전에 await store.persist.flush()를 호출하세요.
미들웨어와 마이그레이션
영속화는 시도한 쓰기가 아닌 실제 commit을 관찰합니다. 검증에서 거절하거나 throttle이 버린 업데이트는 저장하지 않으며 지연된 commit과 재진입 commit은 저장합니다. 복원은 읽기, 저장 형식 검사, migration, decode, 등록된 상태 검증, 즉시 commit 순서입니다. debounce/throttle을 우회하고 상태 검증을 한 번만 실행하며 새 history 기준점이 됩니다. 복원 알림에서 발생한 사용자 편집은 정상 commit으로 저장됩니다.
debounce()는 persist()보다 먼저 선언하세요. 반대 순서는 계속 MIDDLEWARE_ORDER로 거부됩니다. history()와 debounce() 또는 throttle()의 기존 조합 제한은 유지됩니다.
모든 어댑터에 같은 migration 파이프라인이 적용됩니다. 데이터 버전은 migration 배열 길이이며 IndexedDB 스키마 버전, 내부 commit 순번과 구분합니다. 호환성 변경 표와 실행 가능한 예제는 영속화 마이그레이션을 참고하세요.