Chrome Extension State Management — Best Practices
22 min readState Management Patterns
Overview
Chrome extensions scatter state across isolated contexts – background service workers, content scripts, popups, options pages, and side panels. Each has its own memory space and lifecycle. A popup closing destroys its in-memory state. A service worker can terminate at any time. This guide covers eight patterns for managing state reliably using chrome.storage, message passing, and careful architectural choices.
Pattern 1: Centralized State Store Backed by chrome.storage
Create a typed store backed by chrome.storage.local as the single source of truth:
// lib/state-store.ts
interface AppState {
enabled: boolean;
theme: "light" | "dark" | "system";
blocklist: string[];
stats: { pagesProcessed: number; lastRun: number };
}
const DEFAULT_STATE: AppState = {
enabled: true, theme: "system", blocklist: [],
stats: { pagesProcessed: 0, lastRun: 0 },
};
const KEY = "appState";
export class StateStore {
private cache: AppState | null = null;
async get(): Promise<AppState> {
if (this.cache) return this.cache;
const result = await chrome.storage.local.get(KEY);
this.cache = { ...DEFAULT_STATE, ...result[KEY] };
return this.cache;
}
async update(partial: Partial<AppState>): Promise<AppState> {
const current = await this.get();
const next: AppState = { ...current, ...partial };
await chrome.storage.local.set({ [KEY]: next });
this.cache = next;
return next;
}
invalidateCache(): void { this.cache = null; }
}
export const store = new StateStore();
The cache goes stale when another context writes to storage. Keep it fresh:
chrome.storage.onChanged.addListener((changes) => {
if (changes[KEY]) store.invalidateCache();
});
Pattern 2: Pub/Sub Pattern Across Extension Contexts
Build publish/subscribe on top of chrome.runtime messaging so contexts react to state changes without polling:
// lib/pubsub.ts
type Handler<T = unknown> = (data: T) => void;
interface PubSubMessage { __pubsub: true; event: string; data: unknown; }
class ExtensionPubSub {
private handlers = new Map<string, Set<Handler>>();
constructor() {
chrome.runtime.onMessage.addListener((msg) => {
if (typeof msg === "object" && msg?.__pubsub) {
this.notifyLocal(msg.event, msg.data);
}
});
}
on<T>(event: string, handler: Handler<T>): () => void {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler as Handler);
return () => this.handlers.get(event)?.delete(handler as Handler);
}
async emit<T>(event: string, data: T): Promise<void> {
this.notifyLocal(event, data);
const message: PubSubMessage = { __pubsub: true, event, data };
chrome.runtime.sendMessage(message).catch(() => {});
const tabs = await chrome.tabs.query({});
for (const tab of tabs) {
if (tab.id) chrome.tabs.sendMessage(tab.id, message).catch(() => {});
}
}
private notifyLocal(event: string, data: unknown): void {
for (const handler of this.handlers.get(event) ?? []) {
try { handler(data); } catch (err) { console.error(`PubSub error [${event}]:`, err); }
}
}
}
export const pubsub = new ExtensionPubSub();
// popup — subscribe; background — publish
const unsub = pubsub.on<{ enabled: boolean }>("state:updated", (data) => {
document.getElementById("status")!.textContent = data.enabled ? "ON" : "OFF";
});
window.addEventListener("unload", unsub);
Pattern 3: Derived/Computed State from Storage Values
Compute values from raw state without storing redundant data:
// lib/derived-state.ts
import { store, type AppState } from "./state-store";
export async function getDerivedState() {
const state = await store.get();
return {
isActive: state.enabled && state.blocklist.length > 0,
blocklistCount: state.blocklist.length,
hasRunRecently: Date.now() - state.stats.lastRun < 3600_000,
statusLabel: computeLabel(state),
};
}
function computeLabel(state: AppState): string {
if (!state.enabled) return "Disabled";
if (state.blocklist.length === 0) return "No rules configured";
const ago = Date.now() - state.stats.lastRun;
if (ago < 60_000) return "Active — just ran";
return ago < 3600_000 ? "Active" : "Active — idle";
}
For reactive UIs, create a derived store that recomputes on chrome.storage.onChanged:
// lib/stores/derived.ts
export function derivedStorage<T>(
storageKey: string,
deriveFn: (raw: Record<string, unknown>) => T,
initial: T
) {
let current = initial;
const listeners = new Set<(v: T) => void>();
async function refresh() {
const result = await chrome.storage.local.get(storageKey);
current = deriveFn(result[storageKey] ?? {});
for (const fn of listeners) fn(current);
}
chrome.storage.onChanged.addListener((changes) => {
if (changes[storageKey]) refresh();
});
return {
get: async () => { await refresh(); return current; },
subscribe: (handler: (v: T) => void) => {
listeners.add(handler);
refresh();
return () => listeners.delete(handler);
},
};
}
Pattern 4: Undo/Redo with State Snapshots
Maintain a history stack for reversible user actions:
// lib/undo-redo.ts
interface HistoryEntry<T> { state: T; description: string; }
export class UndoRedoManager<T> {
private past: HistoryEntry<T>[] = [];
private future: HistoryEntry<T>[] = [];
private current: T;
constructor(initialState: T, private maxHistory = 50) {
this.current = structuredClone(initialState);
}
push(newState: T, description: string): void {
this.past.push({ state: structuredClone(this.current), description });
if (this.past.length > this.maxHistory) this.past.shift();
this.current = structuredClone(newState);
this.future = [];
}
undo(): T | null {
const entry = this.past.pop();
if (!entry) return null;
this.future.push({ state: structuredClone(this.current), description: entry.description });
this.current = entry.state;
return structuredClone(this.current);
}
redo(): T | null {
const entry = this.future.pop();
if (!entry) return null;
this.past.push({ state: structuredClone(this.current), description: entry.description });
this.current = entry.state;
return structuredClone(this.current);
}
getCurrent(): T { return structuredClone(this.current); }
get canUndo() { return this.past.length > 0; }
get canRedo() { return this.future.length > 0; }
}
// options/rule-editor.ts
const state = await store.get();
const history = new UndoRedoManager(state.blocklist);
async function addRule(rule: string) {
const next = [...history.getCurrent(), rule];
history.push(next, `Add rule: ${rule}`);
await store.update({ blocklist: next });
}
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "z") {
e.preventDefault();
const restored = e.shiftKey ? history.redo() : history.undo();
if (restored) store.update({ blocklist: restored });
}
});
Pattern 5: Transactional State Updates (All-or-Nothing Writes)
When multiple storage keys must update atomically, roll back on failure:
// lib/transaction.ts
interface TransactionOp { key: string; value: unknown; }
export async function transaction(
operations: TransactionOp[],
area: "local" | "sync" = "local"
): Promise<void> {
const storage = area === "sync" ? chrome.storage.sync : chrome.storage.local;
const keys = operations.map((op) => op.key);
const snapshot = await storage.get(keys);
const payload: Record<string, unknown> = {};
for (const op of operations) payload[op.key] = op.value;
try {
await storage.set(payload);
} catch (error) {
try { await storage.set(snapshot); } catch (e) { console.error("Rollback failed:", e); }
throw new Error(`Transaction failed: ${error instanceof Error ? error.message : error}`);
}
}
export async function transactionalUpdate<T extends Record<string, unknown>>(
storageKey: string,
updateFn: (current: T) => T,
validate?: (next: T) => boolean | string
): Promise<T> {
const result = await chrome.storage.local.get(storageKey);
const current = result[storageKey] as T;
const next = updateFn(structuredClone(current));
if (validate) {
const v = validate(next);
if (v !== true) throw new Error(typeof v === "string" ? v : "Validation failed");
}
await chrome.storage.local.set({ [storageKey]: next });
return next;
}
Pattern 6: State Selectors and Memoization
Avoid recomputing expensive derived values when unrelated state changes:
// lib/selectors.ts
type Selector<S, R> = (state: S) => R;
export function createSelector<S, R>(
selector: Selector<S, R>,
isEqual: (a: R, b: R) => boolean = Object.is
): Selector<S, R> {
let lastState: S | undefined;
let lastResult: R | undefined;
return (state: S): R => {
if (lastState !== undefined && state === lastState) return lastResult!;
const result = selector(state);
if (lastResult !== undefined && isEqual(result, lastResult)) return lastResult;
lastState = state;
lastResult = result;
return result;
};
}
export function composeSelectors<S, I, R>(
input: Selector<S, I>,
transform: (i: I) => R
): Selector<S, R> {
let lastInput: I | undefined;
let lastResult: R | undefined;
return (state: S): R => {
const i = input(state);
if (lastInput !== undefined && Object.is(i, lastInput)) return lastResult!;
lastInput = i;
lastResult = transform(i);
return lastResult;
};
}
// Usage
const selectBlocklist = (s: AppState) => s.blocklist;
const selectActiveRules = composeSelectors(
selectBlocklist,
(list) => list.filter((r) => !r.startsWith("#")).length
);
Pattern 7: Cross-Tab State Synchronization via storage.onChanged
Keep all open extension UIs in sync using chrome.storage.onChanged:
// lib/sync-state.ts
type ChangeHandler<T> = (newValue: T, oldValue: T) => void;
export class SyncedState<T extends Record<string, unknown>> {
private handlers = new Map<keyof T, Set<ChangeHandler<unknown>>>();
constructor(private storageKey: string) {
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local" || !changes[this.storageKey]) return;
const oldState = (changes[this.storageKey].oldValue ?? {}) as T;
const newState = (changes[this.storageKey].newValue ?? {}) as T;
for (const key of Object.keys(newState) as (keyof T)[]) {
if (JSON.stringify(oldState[key]) !== JSON.stringify(newState[key])) {
for (const h of this.handlers.get(key) ?? []) {
h(newState[key], oldState[key]);
}
}
}
});
}
watch<K extends keyof T>(key: K, handler: ChangeHandler<T[K]>): () => void {
if (!this.handlers.has(key)) this.handlers.set(key, new Set());
this.handlers.get(key)!.add(handler as ChangeHandler<unknown>);
return () => this.handlers.get(key)?.delete(handler as ChangeHandler<unknown>);
}
}
// popup/main.ts
const synced = new SyncedState<AppState>("appState");
synced.watch("enabled", (val) => {
document.getElementById("status")!.textContent = val ? "ON" : "OFF";
});
synced.watch("theme", (val) => {
document.body.classList.toggle("dark", val === "dark");
});
This is critical when options and popup are open simultaneously – a change in options writes to storage, fires onChanged in the popup, and both stay in sync without explicit messaging.
Pattern 8: State Debugging and Time-Travel Inspection
Log every state mutation for development and troubleshooting:
// lib/state-debugger.ts
interface LogEntry {
timestamp: number; action: string;
prevState: unknown; nextState: unknown;
source: string; duration?: number;
}
class StateDebugger {
private log: LogEntry[] = [];
record(entry: Omit<LogEntry, "timestamp">): void {
this.log.push({ ...entry, timestamp: Date.now() });
if (this.log.length > 200) this.log.shift();
}
getStateAt(index: number): unknown {
return index >= 0 && index < this.log.length
? structuredClone(this.log[index].nextState) : null;
}
printSummary(): void {
console.table(this.log.map((e) => ({
time: new Date(e.timestamp).toLocaleTimeString(),
action: e.action, source: e.source,
duration: e.duration ? `${e.duration}ms` : "-",
})));
}
async persist(): Promise<void> {
await chrome.storage.local.set({ __stateDebugLog: this.log });
}
}
export const stateDebugger = new StateDebugger();
Wrap the store to log every mutation automatically:
// lib/state-store-debug.ts
const originalUpdate = store.update.bind(store);
store.update = async function(partial) {
const prev = await store.get();
const start = performance.now();
const next = await originalUpdate(partial);
stateDebugger.record({
action: "update", prevState: prev, nextState: next,
source: typeof ServiceWorkerGlobalScope !== "undefined" ? "background" : "ui",
duration: performance.now() - start,
});
return next;
};
// Access from DevTools console:
// __stateDebug.printSummary()
// __stateDebug.getStateAt(5)
Summary
| Pattern | What It Solves |
|---|---|
| Centralized state store | Single source of truth backed by chrome.storage |
| Pub/sub across contexts | Reactive communication between popup, background, content |
| Derived/computed state | Avoid storing redundant data; compute on read |
| Undo/redo with snapshots | Reversible user actions in options and editors |
| Transactional updates | All-or-nothing writes that roll back on failure |
| Selectors and memoization | Skip recomputation when unrelated state changes |
| Cross-tab sync via onChanged | Keep multiple open UIs consistent without messaging |
| State debugging | Time-travel inspection and mutation logging for development |
State in a Chrome extension is inherently distributed. These patterns share a common principle: treat chrome.storage as the authoritative store, use onChanged as the synchronization primitive, and layer application logic on top. Keep state shapes flat and serializable – everything that enters chrome.storage must survive JSON round-tripping.
-e
—
Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.