Summary
@ilokesto/state의 persist 미들웨어를 Next.js App Router(SSR) 환경에서 사용할 때 두 가지 결함이 발생합니다.
- 서버 모듈 평가 시
localStorage/sessionStorage에 접근하려 해 ReferenceError로 크래시
- 클라이언트 hydration 시
getServerSnapshot과 getSnapshot이 다른 값을 반환해 React hydration mismatch 경고
Context
React 18의 useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)는 SSR hydration mismatch를 막기 위해 세 번째 인자를 분리해서 받습니다. @ilokesto/state의 React adapter는 이 부분을 올바르게 구현하고 있습니다.
src/core/React/createUseState.ts:
const getServerSnapshot = useMemo(() => {
const shallowSelector = createShallowSelector(selector);
return () => shallowSelector(store.getInitialState());
}, [store, selector]);
store.getInitialState()는 setState 이후에도 변하지 않는 초기값을 반환합니다 (@ilokesto/store의 initialState 보존). 이 설계 자체는 Zustand의 api.getServerState ?? getState보다 더 안전합니다.
하지만 persist 미들웨어가 store 생성 시점에 setState로 state를 덮어써서 getSnapshot이 영속값을 반환하게 만들고, 결국 hydration mismatch가 납니다.
Reproduction
// stores/counter.ts
import { create } from '@ilokesto/state/react'
import { persist } from '@ilokesto/state/middleware'
import { pipe } from '@ilokesto/state/utils'
const counterStore = pipe
.use(persist({ local: 'counter', decode: decodeCounter }))
.create({ count: 0 })
export const useCounter = create(counterStore)
Next.js App Router에서 위 store를 사용하는 컴포넌트를 서버 렌더하면:
1. 서버 크래시
src/middleware/persist/persistUtils.ts의 readStorageValue:
const readStorageValue = (storageType, storageKey) => {
if (storageType === 'local') {
return localStorage.getItem(storageKey) // ← ReferenceError on server
}
if (storageType === 'session') {
return sessionStorage.getItem(storageKey) // ← ReferenceError on server
}
// ...
}
typeof window === 'undefined' 가드가 없어 서버에서 바로 크래시.
2. Hydration mismatch
src/middleware/persist/index.ts의 applyPersist:
const applyPersist = <T>(initialState, options): Store<T> => {
const store = getStore(initialState);
const baseSetState = store.setState.bind(store);
const currentState = store.getState() as T;
const initialValue = optionObj.storageType
? getSafeStorage({ ... }).state
: currentState;
baseSetState(initialValue); // ← 생성 시점에 state를 영속값으로 덮어씀
// ...
};
타임라인:
| 단계 |
서버 |
클라이언트 (hydration 전) |
| 모듈 평가 |
localStorage 접근 → 크래시 (또는 폴백) |
localStorage 읽기 → 영속값 획득 |
applyPersist |
setState(fallback) |
setState(persistedValue) |
getInitialState() |
{ count: 0 } |
{ count: 0 } ✅ |
getState() |
{ count: 0 } |
{ count: 5 } ⚠️ |
getServerSnapshot |
selector({ count: 0 }) |
selector({ count: 0 }) ✅ |
getSnapshot |
N/A |
selector({ count: 5 }) ⚠️ |
| React hydration |
{ count: 0 } 렌더 |
{ count: 0 } 기대하지만 store는 { count: 5 } → mismatch |
getServerSnapshot은 initial로 안전하지만, getSnapshot이 이미 오염된 getState()를 반환해서 mismatch가 발생합니다.
Comparison with Zustand
Zustand persist도 동일한 근본 문제를 갖고 있지만, escape hatch를 제공합니다:
skipHydration: true 옵션으로 생성 시점 자동 hydration을 끌 수 있음
store.persist.rehydrate()로 클라이언트 마운트 후 수동 호출
onRehydrateStorage 콜백으로 hydration 완료 시점 통지
- 공식 문서가 Next.js App Router용 패턴을 명시적으로 문서화
@ilokesto/state는 현재 이에 대응하는 API가 없습니다.
Suggested fix
우선순위대로 세 가지를 제안합니다.
1. readStorageValue에 SSR 가드 (최소 안전장치)
const readStorageValue = (storageType, storageKey) => {
if (typeof window === 'undefined') return null
if (storageType === 'local') {
return localStorage.getItem(storageKey)
}
// ...
}
서버 크래시는 막지만 hydration mismatch는 남음.
2. skipHydration 옵션 + rehydrate() 메서드
persist({ local: 'cart', decode: decodeCart, skipHydration: true })
// 클라이언트에서 수동 호출
useEffect(() => {
cartStore.persist.rehydrate()
}, [])
applyPersist에서 skipHydration: true면 baseSetState(initialValue)를 스킵하고, store에 rehydrate() 메서드를 노출해 클라이언트 마운트 이후에 영속값을 적용. 이 패턴이 Zustand 공식 권장 패턴과 동일합니다.
3. onRehydrateStorage 콜백 (선택)
persist({
local: 'cart',
decode: decodeCart,
skipHydration: true,
onRehydrateStorage: () => (state, error) => { ... }
})
hydration 완료 시점을 컴포넌트에 알려 hydration 게이트를 세울 수 있게 함. 장바구니 카운트 같이 "0 → 실제값" 깜빡임이 허용되는 UX엔 불필요하지만, 다크모드 같이 초기 깜빡임이 거슬리는 경우 필요.
Additional context
docs/guides/lifecycle-reads-writes.mdx에서 "hydrate explicitly before rendering subscribed UI"라고 언급하지만, 그걸 할 API를 제공하지 않고 있음
.changeset/shallow-selector-breaking.md에서 getServerSnapshot의 stale-closure 이슈는 수정된 바 있음 — SSR 관심은 있어 보이지만 persist 쪽은 아직 미흡
- 영속화가 필요하지 않은 일반 store는
getServerSnapshot 분리가 잘 되어있어 SSR에서 안전함. 이 이슈는 persist를 켠 store에만 해당
Acceptance criteria
Summary
@ilokesto/state의persist미들웨어를 Next.js App Router(SSR) 환경에서 사용할 때 두 가지 결함이 발생합니다.localStorage/sessionStorage에 접근하려 해ReferenceError로 크래시getServerSnapshot과getSnapshot이 다른 값을 반환해 React hydration mismatch 경고Context
React 18의
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)는 SSR hydration mismatch를 막기 위해 세 번째 인자를 분리해서 받습니다.@ilokesto/state의 React adapter는 이 부분을 올바르게 구현하고 있습니다.src/core/React/createUseState.ts:store.getInitialState()는setState이후에도 변하지 않는 초기값을 반환합니다 (@ilokesto/store의initialState보존). 이 설계 자체는 Zustand의api.getServerState ?? getState보다 더 안전합니다.하지만
persist미들웨어가 store 생성 시점에setState로 state를 덮어써서getSnapshot이 영속값을 반환하게 만들고, 결국 hydration mismatch가 납니다.Reproduction
Next.js App Router에서 위 store를 사용하는 컴포넌트를 서버 렌더하면:
1. 서버 크래시
src/middleware/persist/persistUtils.ts의readStorageValue:typeof window === 'undefined'가드가 없어 서버에서 바로 크래시.2. Hydration mismatch
src/middleware/persist/index.ts의applyPersist:타임라인:
localStorage접근 → 크래시 (또는 폴백)localStorage읽기 → 영속값 획득applyPersistsetState(fallback)setState(persistedValue)getInitialState(){ count: 0 }{ count: 0 }✅getState(){ count: 0 }{ count: 5 }getServerSnapshotselector({ count: 0 })selector({ count: 0 })✅getSnapshotselector({ count: 5 }){ count: 0 }렌더{ count: 0 }기대하지만 store는{ count: 5 }→ mismatchgetServerSnapshot은 initial로 안전하지만,getSnapshot이 이미 오염된getState()를 반환해서 mismatch가 발생합니다.Comparison with Zustand
Zustand
persist도 동일한 근본 문제를 갖고 있지만, escape hatch를 제공합니다:skipHydration: true옵션으로 생성 시점 자동 hydration을 끌 수 있음store.persist.rehydrate()로 클라이언트 마운트 후 수동 호출onRehydrateStorage콜백으로 hydration 완료 시점 통지@ilokesto/state는 현재 이에 대응하는 API가 없습니다.Suggested fix
우선순위대로 세 가지를 제안합니다.
1.
readStorageValue에 SSR 가드 (최소 안전장치)서버 크래시는 막지만 hydration mismatch는 남음.
2.
skipHydration옵션 +rehydrate()메서드applyPersist에서skipHydration: true면baseSetState(initialValue)를 스킵하고, store에rehydrate()메서드를 노출해 클라이언트 마운트 이후에 영속값을 적용. 이 패턴이 Zustand 공식 권장 패턴과 동일합니다.3.
onRehydrateStorage콜백 (선택)hydration 완료 시점을 컴포넌트에 알려 hydration 게이트를 세울 수 있게 함. 장바구니 카운트 같이 "0 → 실제값" 깜빡임이 허용되는 UX엔 불필요하지만, 다크모드 같이 초기 깜빡임이 거슬리는 경우 필요.
Additional context
docs/guides/lifecycle-reads-writes.mdx에서 "hydrate explicitly before rendering subscribed UI"라고 언급하지만, 그걸 할 API를 제공하지 않고 있음.changeset/shallow-selector-breaking.md에서getServerSnapshot의 stale-closure 이슈는 수정된 바 있음 — SSR 관심은 있어 보이지만 persist 쪽은 아직 미흡getServerSnapshot분리가 잘 되어있어 SSR에서 안전함. 이 이슈는 persist를 켠 store에만 해당Acceptance criteria
persiststore를 Next.js App Router 서버 렌더에서 사용해도 크래시하지 않는다persiststore를 클라이언트 hydration 시 hydration mismatch 경고가 발생하지 않는다skipHydration: true옵션을 제공하면 store 생성 시 자동 hydration을 스킵한다store.persist.rehydrate()로 수동 hydration을 호출할 수 있다