State Management
Quick reference for state management patterns in LARC applications. For detailed tutorials, see Learning LARC Chapter 6.
Overview
State management is the practice of tracking application data across components. LARC distinguishes between local state (component-specific) and shared state (application-wide), using the PAN bus for state synchronization.
Key Concepts:- Local state: Component properties, ephemeral
- Shared state: Store components, persisted via localStorage, IndexedDB, or OPFS
- State stores: Components that manage and publish shared state
- Synchronization: Optimistic updates, debouncing, polling, conflict resolution
Quick Example
// State store component
class UserStore extends HTMLElement {
constructor() {
super();
this.currentUser = null;
}
connectedCallback() {
this.subscriptions = [
subscribe('auth.login.success', (msg) => this.setUser(msg.data)),
subscribe('auth.logout', () => this.setUser(null))
];
this.loadPersistedUser();
}
setUser(user) {
this.currentUser = user;
publish('user.current', user);
if (user) {
localStorage.setItem('currentUser', JSON.stringify(user));
} else {
localStorage.removeItem('currentUser');
}
}
loadPersistedUser() {
const stored = localStorage.getItem('currentUser');
if (stored) {
try {
this.setUser(JSON.parse(stored));
} catch (error) {
console.error('Failed to load user:', error);
}
}
}
disconnectedCallback() {
this.subscriptions.forEach(unsub => unsub());
}
}
customElements.define('user-store', UserStore);
Persistence Strategies
| Strategy | Size Limit | API Style | Use Case | |----------|------------|-----------|----------| | localStorage | 5-10 MB | Synchronous | Small settings, simple data | | IndexedDB | 100s of MB | Async (Promise) | Structured data, queries | | OPFS | GB+ | Async (File API) | Large files, binary data |
When to Use Each
- localStorage: Settings, themes, small JSON (< 100 KB)
- IndexedDB: Documents, cached API responses, structured records
- OPFS: Images, videos, large text files, application data files
State Synchronization Patterns
Optimistic Updates
| Step | Action | |------|--------| | 1 | Update local state immediately | | 2 | Publish updated state to UI | | 3 | Sync to server in background | | 4 | On success: Publish confirmation | | 5 | On error: Rollback and publish error |
async addTodo(todo) {
// Step 1-2: Optimistic update
const temp = { id: `temp-${Date.now()}`, ...todo };
this.todos.push(temp);
publish('todos.loaded', { todos: this.todos });
try {
// Step 3: Sync to server
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(todo)
});
const saved = await response.json();
// Step 4: Replace temp with server ID
this.todos = this.todos.map(t => t.id === temp.id ? saved : t);
publish('todos.loaded', { todos: this.todos });
} catch (error) {
// Step 5: Rollback on error
this.todos = this.todos.filter(t => t.id !== temp.id);
publish('todos.loaded', { todos: this.todos });
publish('todo.error', { error: error.message });
}
}
Debounced Sync
For high-frequency updates (e.g., text editor), debounce server sync while updating UI immediately:
updateContent(content) {
this.content = content;
publish('editor.content.updated', { content }); // Immediate UI update
clearTimeout(this.syncTimer);
this.syncTimer = setTimeout(() => {
this.syncToServer(); // Delayed server sync
}, 1000);
}
Polling
Poll server periodically for updates without WebSockets:
startPolling() {
this.fetchNotifications();
this.pollTimer = setInterval(() => {
this.fetchNotifications();
}, 30000); // Every 30 seconds
}
Conflict Resolution Strategies
| Strategy | Description | Use Case | |----------|-------------|----------| | Last Write Wins | Most recent update overwrites | Simple apps, rare conflicts | | Timestamps | Keep update with newest timestamp | Async updates, out-of-order messages | | Version Vectors | Track causality across clients | Distributed systems, offline-first | | User Intervention | Detect conflict, prompt user | Collaborative apps, important data |
Example: Timestamp-Based Resolution
connectedCallback() {
this.unsubscribe = subscribe('data.update', (msg) => {
const { key, value, timestamp } = msg.data;
// Only apply if newer
if (!this.timestamps[key] || timestamp > this.timestamps[key]) {
this.data[key] = value;
this.timestamps[key] = timestamp;
publish('data.current', this.data);
}
});
}
Derived State Pattern
Compute derived state from source state rather than storing redundantly:
publishDerivedState() {
const itemCount = this.items.reduce((sum, item) => sum + item.quantity, 0);
const subtotal = this.items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
const tax = subtotal * 0.08;
const total = subtotal + tax;
publish('cart.state', {
items: this.items,
itemCount,
subtotal,
tax,
total
});
}
History and Time Travel
Implement undo/redo by maintaining state snapshots:
addSnapshot(state) {
this.history = this.history.slice(0, this.currentIndex + 1);
this.history.push(JSON.parse(JSON.stringify(state)));
this.currentIndex++;
if (this.history.length > this.maxHistory) {
this.history.shift();
this.currentIndex--;
}
publish('state.current', state);
publish('state.history.updated', {
canUndo: this.currentIndex > 0,
canRedo: this.currentIndex < this.history.length - 1
});
}
Performance Best Practices
| Practice | Why | |----------|-----| | Minimize updates | Only publish when state actually changes | | Batch updates | Combine multiple field changes into single message | | Immutable updates | Create new objects, don't mutate | | Debounce high-frequency | Don't publish every keystroke | | Lazy load | Load data on demand | | Prune old data | Remove stale data to prevent memory bloat |
Component Reference
- pan-store: Reactive state store with persistence - See Chapter 18
- pan-idb: IndexedDB wrapper component - See Chapter 18
Complete Example: Document Store with IndexedDB
class DocumentStore extends HTMLElement {
constructor() {
super();
this.db = new IndexedDBStore('app-db', 'documents');
this.documents = [];
}
async connectedCallback() {
this.subscriptions = [
subscribe('document.save', async (msg) => await this.saveDocument(msg.data)),
subscribe('document.delete', async (msg) => await this.deleteDocument(msg.data.id)),
subscribe('document.load', async (msg) => await this.loadDocument(msg.data.id))
];
await this.loadAllDocuments();
}
async loadAllDocuments() {
try {
this.documents = await this.db.getAll();
publish('documents.loaded', { documents: this.documents });
} catch (error) {
console.error('Failed to load documents:', error);
publish('documents.error', { error: error.message });
}
}
async saveDocument(document) {
try {
await this.db.put(document);
this.documents = await this.db.getAll();
publish('document.saved', { document });
publish('documents.loaded', { documents: this.documents });
} catch (error) {
console.error('Failed to save document:', error);
publish('document.error', { error: error.message });
}
}
async deleteDocument(id) {
try {
await this.db.delete(id);
this.documents = await this.db.getAll();
publish('document.deleted', { id });
publish('documents.loaded', { documents: this.documents });
} catch (error) {
console.error('Failed to delete document:', error);
publish('document.error', { error: error.message });
}
}
async loadDocument(id) {
try {
const document = await this.db.get(id);
publish('document.loaded', { document });
} catch (error) {
console.error('Failed to load document:', error);
publish('document.error', { error: error.message });
}
}
disconnectedCallback() {
this.subscriptions.forEach(unsub => unsub());
}
}
customElements.define('document-store', DocumentStore);
Cross-References
- Tutorial: Learning LARC Chapter 6 (State Management)
- Components: Chapter 18 (pan-store, pan-idb)
- Patterns: Appendix E (Message Patterns)
- Related: Chapter 5 (Routing), Chapter 7 (Data Fetching)
Common Issues
Issue: State not persisting
Problem: Data lost on page reload Solution: EnsureloadPersistedUser() called in connectedCallback() and storage API used correctly
Issue: Race conditions
Problem: Concurrent updates causing inconsistent state Solution: Use timestamps or version vectors, implement conflict resolution strategyIssue: Memory leaks from subscriptions
Problem: Memory grows over time Solution: Always unsubscribe indisconnectedCallback(), store subscription functions
Issue: localStorage quota exceeded
Problem:QuotaExceededError thrown
Solution: Migrate to IndexedDB for larger data, implement data pruning strategy
Issue: Stale data after optimistic update failure
Problem: UI shows incorrect state after server error Solution: Implement rollback in catch block, publish error stateSee Learning LARC Chapter 6 for detailed troubleshooting and advanced patterns.