xiaohongshu
xiaohongshu
idb-state-persistence
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...
Full skill instructions
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:
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 listenersThis project uses three IDB patterns working together:
Architecture: Web Components → State Manager (#cache, #listeners) → StateStore/Specialized Store → IndexedDB
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.
/**
* @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:
Date.now() (milliseconds since epoch)[id] syntax in JSDoc for auto-generated or conditional fieldsCreate a module-level constant with complete initial state:
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:
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:
dbPromise is reused across all operationsif (!db.objectStoreNames.contains(...))Object Store Naming:
sessions, stories, achievementsphoneme_mastery (underscore OK for multi-word)Key Paths:
activityId, id){ keyPath: 'id', autoIncrement: true })Indexes:
Use this for single-document state (game state, settings, user profile).
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).
Use this for collections with queries (stories, sessions, achievements).
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 indexopenCursor() for pagination or limit resultsWhen to use: Multiple documents with filtering/sorting needs (story versions, practice sessions).
Use this for complex state with business logic (game progression, navigation).
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.
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.
Write-Through Pattern (current implementation):
async updateSetting(key, value) {
this.#cache.settings[key] = value;
await this.#persist(); // Immediate write
this.#notify('settingsChanged', this.#cache.settings);
}
When to persist:
Alternative: Debounced Persistence (not currently used, but valid):
#persistTimeout = null;
async #debouncedPersist(delay = 500) {
clearTimeout(this.#persistTimeout);
this.#persistTimeout = setTimeout(async () => {
await StateStore.set(this.KEY, this.#cache);
}, delay);
}
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"
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
async clearAll() {
await StateStore.clear(); // Clears entire state object store
}
Use when: Nuking all app state (debug, testing, logout)
Merge pattern ensures missing fields get defaults:
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.
State Managers provide the bridge between IDB and UI via event subscriptions.
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);
}
}
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:
#cache, persists to IDB, notifies subscribersweb-components skill)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.
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.
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.
❌ 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
Schema Migration: Increment DB_VERSION, check oldVersion in upgrade callback:
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.
await manager.ready() in connectedCallbackonChange()await ready() → Cache is null, methods failjavascript: Error handling (Rule 1), timeouts (Rule 2), cleanup (Rule 4)web-components: Component lifecycle, event-driven updates, attribute patternsjs-micro-utilities: Use debounce from utilities if implementing debounced persistencejs/services/StateStore.jsfantasy-phonics-dbstate (single object store, no keyPath)js/services/StoryStore.jsfantasy-phonics-storiesstories (keyPath: id)wordSeq, createdAtjs/services/PracticeProgressStore.jsfantasy-phonics-practiceprogress, sessions, achievements, phoneme_masteryxiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience