---
id: wiki-2026-0508-zustand-based-mission-persistenc
title: Zustand-Based Mission Persistence
category: 10_Wiki/Topics
status: verified
canonical_id: self
aliases: [Zustand Persist Pattern, Game Mission State, Long-running Task Store]
duplicate_of: none
source_trust_level: A
confidence_score: 0.88
verification_status: applied
tags: [zustand, state-management, persistence, react, game-state]
raw_sources: []
last_reinforced: 2026-05-10
github_commit: pending
tech_stack:
  language: TypeScript
  framework: Zustand 5 + IndexedDB / AsyncStorage
---

# Zustand-Based Mission Persistence

## 매 한 줄
> **"매 long-running mission/quest state 를 Zustand store 에 normalized 하게 둔 다음, persist middleware 로 IndexedDB/AsyncStorage 에 매 incremental sync"**. 게임의 quest, agentic LLM workflow, multi-step onboarding 모두 동일 패턴. 매 3kb store + middleware 만으로 Redux + redux-persist 를 대체.

## 매 핵심

### 매 Mission state 모델
- `missions: Record<MissionId, Mission>` (normalized).
- `Mission = { id, status, steps[], currentStepIdx, payload, startedAt, updatedAt }`.
- Active set 은 derived selector: `Object.values(missions).filter(m => m.status === 'active')`.

### 매 Persistence layer
- **web**: `persist` middleware + custom IndexedDB storage (idb-keyval).
- **RN**: persist + AsyncStorage / MMKV.
- **partialize**: 매 transient state (UI loading, animation flags) 의 제외.
- **version + migrate**: schema 변경 시 매 graceful upgrade.

### 매 Crash safety
- Step 완료 직후 `set` → middleware 가 매 microtask 의 disk flush.
- 매 atomic write — 매 mid-write crash 도 magic header 또는 swap-on-write 로 ok.
- 매 startup 의 `hydrate()` 후 매 `inProgressStep` 의 resume.

### 매 응용
1. RPG quest tracker (Zen-Pop 같은 calm UI 와 겹침).
2. Agent / LLM tool-use loop (Claude tool-use loop persistence).
3. Multi-step form / onboarding wizard.
4. Offline-first todo / habit tracker.

## 💻 패턴

### Store 정의
```typescript
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { get as idbGet, set as idbSet, del as idbDel } from 'idb-keyval';

type Step = { id: string; status: 'pending' | 'running' | 'done' | 'failed'; output?: unknown };
type Mission = {
  id: string;
  title: string;
  status: 'active' | 'paused' | 'completed' | 'failed';
  steps: Step[];
  currentStepIdx: number;
  payload: Record<string, unknown>;
  startedAt: number;
  updatedAt: number;
};

interface State {
  missions: Record<string, Mission>;
  start: (m: Omit<Mission, 'startedAt' | 'updatedAt' | 'currentStepIdx' | 'status'>) => void;
  advance: (id: string, output: unknown) => void;
  fail: (id: string, err: string) => void;
  complete: (id: string) => void;
}

const idbStorage = {
  getItem: (k: string) => idbGet(k).then(v => v ?? null),
  setItem: (k: string, v: string) => idbSet(k, v),
  removeItem: (k: string) => idbDel(k),
};

export const useMissions = create<State>()(
  persist(
    (set) => ({
      missions: {},
      start: (m) => set(s => ({
        missions: { ...s.missions, [m.id]: { ...m, status: 'active', currentStepIdx: 0, startedAt: Date.now(), updatedAt: Date.now() } },
      })),
      advance: (id, output) => set(s => {
        const m = s.missions[id]; if (!m) return s;
        const steps = m.steps.map((st, i) => i === m.currentStepIdx ? { ...st, status: 'done' as const, output } : st);
        const nextIdx = m.currentStepIdx + 1;
        const done = nextIdx >= steps.length;
        return { missions: { ...s.missions, [id]: {
          ...m, steps, currentStepIdx: done ? m.currentStepIdx : nextIdx,
          status: done ? 'completed' : 'active', updatedAt: Date.now(),
        } } };
      }),
      fail: (id, err) => set(s => {
        const m = s.missions[id]; if (!m) return s;
        return { missions: { ...s.missions, [id]: { ...m, status: 'failed', payload: { ...m.payload, err }, updatedAt: Date.now() } } };
      }),
      complete: (id) => set(s => ({ missions: { ...s.missions, [id]: { ...s.missions[id], status: 'completed', updatedAt: Date.now() } } })),
    }),
    {
      name: 'mission-store-v2',
      storage: createJSONStorage(() => idbStorage),
      version: 2,
      migrate: (state: any, from) => {
        if (from < 2) state.missions = state.missions ?? {};
        return state;
      },
      partialize: (s) => ({ missions: s.missions }),
    },
  ),
);
```

### Resume on app start
```typescript
useEffect(() => {
  const unsub = useMissions.persist.onFinishHydration((s) => {
    Object.values(s.missions).forEach(m => {
      if (m.status === 'active') resumeMission(m);
    });
  });
  return unsub;
}, []);
```

### Selector hooks
```typescript
export const useActiveMissions = () =>
  useMissions(s => Object.values(s.missions).filter(m => m.status === 'active'), shallow);

export const useMission = (id: string) => useMissions(s => s.missions[id]);
```

### Server-sync (optimistic)
```typescript
async function advanceWithSync(id: string, output: unknown) {
  useMissions.getState().advance(id, output);  // local instant
  try {
    await fetch(`/api/missions/${id}/advance`, { method: 'POST', body: JSON.stringify({ output }) });
  } catch {
    // 매 retry queue 에 push, persisted store 가 source of truth
  }
}
```

### Devtools + immer
```typescript
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

create<State>()(devtools(persist(immer((set) => ({
  // immer 의 mutating syntax
  advance: (id, output) => set(s => { s.missions[id].steps[s.missions[id].currentStepIdx].status = 'done'; }),
})), { name: 'missions' })));
```

## 매 결정 기준
| 상황 | Approach |
|---|---|
| Web (≤ 5MB state) | persist + IndexedDB (idb-keyval) |
| RN (mobile) | persist + MMKV (≫ AsyncStorage perf) |
| Cross-tab sync | persist + `storage` event 또는 BroadcastChannel |
| Multi-user / cloud | local store + server sync layer (TanStack Query mutate) |
| Real CRDT collab | Yjs + Zustand bridge |

**기본값**: Zustand 5 + persist + idb-keyval + version/migrate + partialize + immer middleware.

## 🔗 Graph
- 부모: [[Zustand]]
- Adjacent: [[MMKV]] · [[Optimistic-UI]]

## 🤖 LLM 활용
**언제**: store scaffolding, migrate function, selector hook 도출.
**언제 X**: 매 storage size / quota 결정, 매 multi-tab race condition debug — 매 empirical.

## ❌ 안티패턴
- **Persisting ephemeral UI flags**: 매 reload 시 stale loading spinner. Use partialize.
- **No version field**: 매 schema 변경 시 매 user data loss.
- **Direct localStorage on RN**: 매 size limit + sync. MMKV.
- **Whole array re-create on each step**: 매 selector subscribers re-render. Use immer or fine-grained slicing.
- **Storing secret tokens**: persisted store 의 plaintext leak. Use secure keychain.

## 🧪 검증 / 중복
- Verified (Zustand 5 docs, idb-keyval / MMKV docs, prod RPG/agent codebases).
- 신뢰도 A.

## 🕓 Changelog
| 날짜 | 변경 |
|---|---|
| 2026-05-08 | Phase 1 |
| 2026-05-10 | Manual cleanup — Zustand persist + mission state pattern |
