idb-state-persistence
π―Skillfrom matthewharwood/fantasy-phonics
idb-state-persistence skill from matthewharwood/fantasy-phonics
Installation
npx skills add https://github.com/matthewharwood/fantasy-phonics --skill idb-state-persistenceSkill Details
IndexedDB patterns for local-first state persistence using the idb library. Use when implementing features that require persistent state across navigation and sessions. Covers data modeling, defaults, CRUD operations, state managers, and reset patterns. Integrates with web-components for reactive UI updates.
Overview
# IDB State Persistence Architecture
Philosophy
Local Database First: IndexedDB (IDB) is the primary source of truth for all application state. Every user interaction that changes state MUST persist to IDB immediately to survive navigation, page refreshes, and session restarts.
This architecture treats IDB as a local database, not a cache. State lives in IDB first, components read from it, and all mutations write through to IDB synchronously (no debouncing or batching by default).
Why IDB over localStorage:
- Structured data storage (objects, arrays, not just strings)
- Indexed queries for efficient retrieval
- Larger storage limits (hundreds of MB vs 5-10 MB)
- Async API that doesn't block main thread
Relationship with Other Skills:
javascript: Provides error handling (Rule 1), timeout patterns (Rule 2), cleanup (Rule 4)web-components: Components subscribe to IDB state changes via event listeners- Integration: State Managers act as the bridge between IDB and UI components
---
Core Patterns Overview
This project uses three IDB patterns working together:
- StateStore - Generic key-value store (simple CRUD, single object store)
- Specialized Stores - Domain-specific stores with indexes (StoryStore, PracticeProgressStore)
- State Managers - Wrapper classes that use StateStore + event system (GameStateManager, PracticeStateManager)
Architecture: Web Components β State Manager (#cache, #listeners) β StateStore/Specialized Store β IndexedDB
---
Data Modeling Pattern
Principle: Define data structures using TypeScript-style JSDoc typedefs BEFORE writing any IDB code. Start with primitives, build objects, then compose arrays and nested structures.
Step 1: Define Types with JSDoc
```javascript
/**
* @typedef {Object} ActivityProgress
* @property {string} activityId - Unique activity identifier
* @property {number} tier - Tier number (1-5)
* @property {number} highScore - Best score achieved
* @property {number} totalAttempts - Total play count
* @property {number} currentStreak - Consecutive successful days
* @property {number} lastPlayed - Timestamp of last session
*/
/**
* @typedef {Object} PracticeSession
* @property {number} [id] - Auto-generated ID (optional, IDB creates it)
* @property {string} activityId - References ActivityProgress
* @property {number} timestamp - When session occurred
* @property {number} score - Session score (0-100)
* @property {string[]} phonemesTested - Array of phoneme IDs
*/
```
Conventions:
- Primitives first: string, number, boolean
- Timestamps: Always use
Date.now()(milliseconds since epoch) - Optional fields: Use
[id]syntax in JSDoc for auto-generated or conditional fields - References: Use matching ID fields (activityId links to ActivityProgress.activityId)
- Arrays of objects: Define the object type first, then reference in array
Step 2: Define Default State
Create a module-level constant with complete initial state:
```javascript
const DEFAULT_PRACTICE_STATE = {
words: [],
phonemes: [],
currentTier: 1,
currentWordIndex: 0,
settings: {
autoAdvance: true,
soundEnabled: true,
showHints: true,
tierUnlocked: 5
},
status: 'loading' // loading, ready, error
};
```
Conventions:
- Complete structure: Include ALL fields, even empty arrays/objects
- Smart defaults: Choose sensible starting values (tier: 1, index: 0)
- Status field: Always include a status field ('loading', 'ready', 'error')
- Nested objects: Define full shape, don't use null for object properties
- Use CONST: Defaults are immutable references, clone when using
---
Database Initialization Pattern
Pattern: Lazy Initialization with Module-Level Cache
```javascript
import { openDB } from 'idb';
const DB_NAME = 'fantasy-phonics-practice';
const DB_VERSION = 1;
const STORE_NAME = 'progress';
// Module-level promise cache (singleton pattern)
let dbPromise = null;
function getDB() {
if (!dbPromise) {
dbPromise = openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
// Schema creation ONLY runs when DB doesn't exist or version changes
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: 'activityId' // Primary key from data
});
// Create indexes for common queries
store.createIndex('tier', 'tier', { unique: false });
store.createIndex('lastPlayed', 'lastPlayed', { unique: false });
}
}
});
}
return dbPromise;
}
```
Key Points:
- One database per feature area: Separate DBs for game vs practice vs stories
- Lazy init: Only create connection when first needed
- Module-level cache:
dbPromiseis reused across all operations - Upgrade callback: Runs ONLY on version increment or first creation
- Check existence: Always
if (!db.objectStoreNames.contains(...))
Schema Design Principles
Object Store Naming:
- Plural nouns:
sessions,stories,achievements - Lowercase, no hyphens:
phoneme_mastery(underscore OK for multi-word)
Key Paths:
- keyPath: Use existing property for primary key (
activityId,id) - autoIncrement: Use when IDs are auto-generated (
{ keyPath: 'id', autoIncrement: true }) - Out-of-line keys: Don't use if you can use keyPath (simpler API)
Indexes:
- Create for frequently filtered fields (tier, date, timestamp)
- Create for sorting operations (createdAt, lastPlayed)
- Keep unique: false unless truly unique constraint needed
---
CRUD Operations
Pattern 1: Generic StateStore (Simple Key-Value)
Use this for single-document state (game state, settings, user profile).
```javascript
export const StateStore = {
async get(key) {
const db = await getDB();
return db.get(STORE_NAME, key);
},
async set(key, value) {
const db = await getDB();
return db.put(STORE_NAME, value, key);
},
async delete(key) {
const db = await getDB();
return db.delete(STORE_NAME, key);
},
async clear() {
const db = await getDB();
return db.clear(STORE_NAME);
},
// Helper: Get with fallback
async getOrDefault(key, fallback) {
const value = await this.get(key);
return value ?? fallback;
},
// Helper: Update function pattern
async update(key, updater) {
const current = await this.get(key);
const updated = updater(current);
await this.set(key, updated);
return updated;
}
};
```
When to use: Single state object per key (gameState, practiceMode, userSettings).
Pattern 2: Specialized Store with Indexes
Use this for collections with queries (stories, sessions, achievements).
```javascript
export const StoryStore = {
// CREATE
async save(storyData) {
const db = await getDB();
const story = {
id: generateId(), // Custom ID generation
wordSeq: storyData.wordSeq,
createdAt: Date.now(),
answers: storyData.answers
};
await db.put(STORE_NAME, story);
return story;
},
// READ - Single
async getById(id) {
const db = await getDB();
return db.get(STORE_NAME, id);
},
// READ - Collection (all)
async getAll() {
const db = await getDB();
return db.getAll(STORE_NAME);
},
// READ - Indexed query
async getByWord(wordSeq) {
const db = await getDB();
return db.getAllFromIndex(STORE_NAME, 'wordSeq', wordSeq);
},
// UPDATE
async update(id, updates) {
const db = await getDB();
const existing = await db.get(STORE_NAME, id);
if (!existing) throw new Error(Not found: ${id});
const updated = { ...existing, ...updates };
await db.put(STORE_NAME, updated);
return updated;
},
// DELETE
async delete(id) {
const db = await getDB();
await db.delete(STORE_NAME, id);
},
// CLEAR ALL
async clear() {
const db = await getDB();
await db.clear(STORE_NAME);
}
};
```
Index Queries:
db.getAllFromIndex(store, indexName, value)- Get all matching valuedb.getAllFromIndex(store, indexName)- Get all, sorted by index- Use
openCursor()for pagination or limit results
When to use: Multiple documents with filtering/sorting needs (story versions, practice sessions).
Pattern 3: State Manager with Cache
Use this for complex state with business logic (game progression, navigation).
```javascript
class GameStateManager {
#cache = null;
#listeners = new Map();
#ready;
constructor() {
this.#ready = this.#init();
}
async #init() {
const stored = await StateStore.get(KEY);
this.#cache = stored ? { ...DEFAULT, ...stored } : { ...DEFAULT };
if (!this.#cache.answers) this.#cache.answers = {}; // Ensure nested objects
return this;
}
async ready() { await this.#ready; return this; }
getState() { return { ...this.#cache }; }
async submitAnswer(text) {
this.#cache.answers[this.#cache.currentSeq] = { answer: text, timestamp: Date.now() };
await this.#persist();
this.#notify('answerSubmitted', { answer: text });
}
async #persist() { await StateStore.set(KEY, this.#cache); }
onChange(event, callback) {
if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
this.#listeners.get(event).add(callback);
return () => this.#listeners.get(event)?.delete(callback);
}
#notify(event, data) { this.#listeners.get(event)?.forEach(cb => cb(data)); }
}
export const gameState = new GameStateManager(); // Singleton
```
Key: Private cache, write-through persistence, pub/sub events, singleton export, async init pattern.
---
State Lifecycle Patterns
Initialization Flow
```javascript
async connectedCallback() {
await gameState.ready();
await gameState.loadAllData(); // Fetch JSON if needed
this.#unsubscribe = gameState.onChange('state', (data) => this.#render(data));
}
disconnectedCallback() {
if (this.#unsubscribe) this.#unsubscribe();
}
```
Pattern: Constructor starts #init() β ready() awaits β load data β subscribe β cleanup in disconnectedCallback.
Persistence Triggers
Write-Through Pattern (current implementation):
```javascript
async updateSetting(key, value) {
this.#cache.settings[key] = value;
await this.#persist(); // Immediate write
this.#notify('settingsChanged', this.#cache.settings);
}
```
When to persist:
- β After every state mutation (current pattern)
- β After data loading completes
- β After navigation/phase changes
- β οΈ NOT after every keystroke (implement debouncing at component level if needed)
Alternative: Debounced Persistence (not currently used, but valid):
```javascript
#persistTimeout = null;
async #debouncedPersist(delay = 500) {
clearTimeout(this.#persistTimeout);
this.#persistTimeout = setTimeout(async () => {
await StateStore.set(this.KEY, this.#cache);
}, delay);
}
```
---
Reset and Clear Patterns
Pattern 1: Full Reset (Delete & Reinitialize)
```javascript
async resetAll() {
// Clear cache to defaults
this.#cache = { ...DEFAULT_GAME_STATE };
this.#cache.answers = {};
this.#cache.completedWords = [];
// Persist clean state
await this.#persist();
// Reload external data
await this.loadAllData();
// Notify
this.#notify('reset', null);
this.#notify('state', this.getState());
}
```
Use when: User requests "Start Over" or "Reset Progress"
Pattern 2: Selective Clear (Delete by Key)
```javascript
async clearProgress(activityId) {
const db = await getDB();
await db.delete(STORES.PROGRESS, activityId);
}
async clearAllProgress() {
const db = await getDB();
await db.clear(STORES.PROGRESS);
}
```
Use when: Removing specific entries without affecting other data
Pattern 3: StateStore Clear (Entire Store)
```javascript
async clearAll() {
await StateStore.clear(); // Clears entire state object store
}
```
Use when: Nuking all app state (debug, testing, logout)
Default Restoration Logic
Merge pattern ensures missing fields get defaults:
```javascript
async #init() {
const stored = await StateStore.get(KEY);
// Merge stored with defaults (stored wins for existing keys)
this.#cache = stored ? { ...DEFAULT_STATE, ...stored } : { ...DEFAULT_STATE };
// Explicitly ensure nested objects exist (spread doesn't deep merge)
if (!this.#cache.answers) this.#cache.answers = {};
if (!this.#cache.settings) this.#cache.settings = { ...DEFAULT_STATE.settings };
}
```
Critical: Spread operator (...) only does shallow merge. Nested objects need explicit checks.
---
Integration with Web Components
State Managers provide the bridge between IDB and UI via event subscriptions.
Pattern: Subscribe in connectedCallback
```javascript
class MyComponent extends HTMLElement {
#unsubscribe = null;
async connectedCallback() {
await gameState.ready();
// Subscribe to state changes
this.#unsubscribe = gameState.onChange('state', (newState) => {
this.#handleStateChange(newState);
});
// Initial render
this.#handleStateChange(gameState.getState());
}
disconnectedCallback() {
// CRITICAL: Clean up subscription
if (this.#unsubscribe) this.#unsubscribe();
}
#handleStateChange(state) {
// Update attributes (triggers re-render via attributeChangedCallback)
this.setAttribute('current-phase', state.currentPhase);
this.setAttribute('progress', state.overallProgress);
}
}
```
Pattern: User Action β State Update β Persistence
```javascript
class AnswerInput extends HTMLElement {
handleEvent(e) {
if (e.type === 'submit') {
e.preventDefault();
const answer = this.getAttribute('value');
// Trigger state update (which persists internally)
gameState.submitAnswer(answer);
// State change notification will trigger re-render
}
}
}
```
Flow:
- User interacts with component
- Component calls State Manager method
- State Manager updates
#cache, persists to IDB, notifies subscribers - Component receives notification, updates attributes
- Attribute change triggers re-render (per
web-componentsskill)
---
Advanced Patterns
Cursor Pagination: Use index.openCursor(null, 'prev') + while loop for large collections.
Aggregation: Load all with getAll(), reduce in memory (fine for <1000 records).
Versioning: Query existing count, increment version field: version = existing.length + 1.
---
Error Handling and Recovery
Pattern: Try-Catch with Context (from javascript skill)
```javascript
async loadAllData() {
try {
const data = await this.#fetchWithTimeout('/data/words.json');
this.#cache.words = data;
this.#cache.status = 'ready';
await this.#persist();
this.#notify('dataLoaded', true);
return true;
} catch (error) {
const errorMessage = error.name === 'AbortError'
? Data load timed out after ${TIMEOUT}ms
: error.message;
console.error('Failed to load data:', errorMessage, error);
this.#cache.status = 'error';
this.#notify('error', errorMessage);
return false;
}
}
```
Apply javascript Rule 1: Provide specific error context in catch blocks.
Timeout Pattern for Fetches (from javascript skill)
```javascript
async #fetchWithTimeout(url, timeout = 5_000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) throw new Error(HTTP ${response.status});
return await response.json();
} finally {
clearTimeout(timeoutId);
}
}
```
Apply javascript Rule 2: Time-bound all async operations.
---
Anti-Patterns (What NOT to Do)
β Don't use localStorage - Max 5-10MB, blocks main thread, no indexing
β Don't store state in component fields - Lost on navigation/refresh
β Don't access IDB from components - Bypasses State Manager, breaks reactivity
β Don't forget await ready() - Cache is null before init completes
β Don't mutate cache without persisting - Changes lost on refresh
β Don't use nested transactions for simple ops - Single-store operations don't need transactions
---
Migration and Versioning
Schema Migration: Increment DB_VERSION, check oldVersion in upgrade callback:
```javascript
upgrade(db, oldVersion) {
if (oldVersion < 2) {
const store = transaction.objectStore(STORE_NAME);
if (!store.indexNames.contains('category')) {
store.createIndex('category', 'category', { unique: false });
}
}
}
```
Data Migration: Iterate records in upgrade, add missing fields with defaults. Test thoroughlyβmigrations run once per user.
---
Quick Reference: Adding a New Feature with IDB
Checklist
- Define data model (JSDoc typedefs for all entities)
- Define DEFAULT state (complete structure with sensible values)
- Choose pattern:
- Simple key-value? β Use StateStore directly
- Collection with queries? β Create specialized store with indexes
- Complex state with navigation? β Create State Manager class
- Implement CRUD (get, set, update, delete, clear)
- Add persistence triggers (call persist after mutations)
- Implement reset (clear β defaults β persist)
- Export singleton (if using State Manager)
- Integrate with components:
- await manager.ready() in connectedCallback
- Subscribe via onChange()
- Clean up in disconnectedCallback
- Test lifecycle:
- Fresh state (no IDB data)
- Existing state (reload page)
- Reset flow
- Navigation persistence
---
Common Pitfalls
- Forgot
await ready()β Cache is null, methods fail - Shallow merge loses nested objects β Explicitly check nested fields after merge
- No cleanup in disconnectedCallback β Memory leaks from subscriptions
- Direct cache mutation without persist β Changes lost on refresh
- Using getAll() on large collections β Use cursors for pagination
- Not handling IDB errors β Quota exceeded, corrupted DB crashes app
- Storing functions/classes β IDB only supports structured cloneable data
---
Related Skills
javascript: Error handling (Rule 1), timeouts (Rule 2), cleanup (Rule 4)web-components: Component lifecycle, event-driven updates, attribute patternsjs-micro-utilities: Usedebouncefrom utilities if implementing debounced persistence
---
Reference: Current Project Stores
StateStore (Generic KV)
- File:
js/services/StateStore.js - DB:
fantasy-phonics-db - Store:
state(single object store, no keyPath) - Usage: Game state, practice mode state, word progress
StoryStore (Specialized)
- File:
js/services/StoryStore.js - DB:
fantasy-phonics-stories - Store:
stories(keyPath:id) - Indexes:
wordSeq,createdAt - Usage: Saved story versions
PracticeProgressStore (Specialized)
- File:
js/services/PracticeProgressStore.js - DB:
fantasy-phonics-practice - Stores:
progress,sessions,achievements,phoneme_mastery - Indexes: Various per store
- Usage: Practice mode analytics and progress tracking
State Managers
- GameStateManager: Wraps StateStore, manages 28-word progression
- PracticeStateManager: Wraps StateStore, manages tier/word navigation
- WordProgressManager: Wraps StateStore, tracks phase completion
More from this repository10
ux-design-principles skill from matthewharwood/fantasy-phonics
web-audio skill from matthewharwood/fantasy-phonics
audio-design skill from matthewharwood/fantasy-phonics
animejs-v4 skill from matthewharwood/fantasy-phonics
ipad-pro-design skill from matthewharwood/fantasy-phonics
utopia-fluid-scales skill from matthewharwood/fantasy-phonics
ux-user-flow skill from matthewharwood/fantasy-phonics
ux-spacing-layout skill from matthewharwood/fantasy-phonics
material-symbols-v3 skill from matthewharwood/fantasy-phonics
ux-animation-motion skill from matthewharwood/fantasy-phonics