react-time-warp
v0.2.0
Published
Zero-config state DVR for React — record, rewind and replay component state
Downloads
309
Maintainers
Readme
react-time-warp 🕰️
Zero-config state DVR for React components — record, rewind, and replay state history at runtime.
Why?
React DevTools and Redux DevTools are great for debugging, but they require setup, a store, or browser extensions. react-time-warp is a lightweight, in-app runtime solution for:
- Undo/redo without implementing it from scratch (the actual undo/redo, not just "history observation")
- Replaying user interactions for debugging in production
- Rewinding component state during demos or onboarding flows
- Exporting state history for post-mortem debugging
Features
✅ Zero setup — Works with any useState / useReducer
✅ Lightweight — 4.45 KB brotli (hooks only), 7.96 KB brotli with full UI
✅ Tree-shakeable — sideEffects: false; only pay for what you import
✅ Type-safe — Full TypeScript generics, including a select projection generic
✅ SSR-safe — No window/sessionStorage access on the server
✅ Stable references — Memoized API + history array for downstream useMemo/useEffect deps
✅ Real undo/redo — useTimelineState actually mutates state on rewind
✅ Replay export/import — JSON-serializable timelines for bug reports
✅ Reducer support — useTimelineReducer for action-driven state
✅ Keyboard shortcuts — Opt-in Cmd+Z / Cmd+Shift+Z hook
✅ Storage choice — sessionStorage, localStorage, or custom adapter (IndexedDB, in-memory, encrypted…)
✅ Visual debugger — Built-in collapsible timeline UI with JSON ↔ Diff view
✅ Security layer — Size caps, type whitelist, sanitization, telemetry
✅ Selectors — Project state to a slice; ignore noise like cursor/scroll position
✅ Batching — timeline.batch(fn) collapses multiple setState calls into one snapshot (sync + async)
Installation
npm install react-time-warpQuick Start
There are three ways to use this library, in increasing order of "opinionation":
1. useTimelineState — the recommended "drop-in undo/redo" hook
This is what most people want. It owns the state AND the timeline.
import { useTimelineState, useUndoRedoShortcuts } from 'react-time-warp';
function TextEditor() {
const [text, setText, timeline] = useTimelineState('');
// Wire Cmd+Z / Cmd+Shift+Z to the timeline
useUndoRedoShortcuts(timeline);
return (
<>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<button onClick={timeline.back} disabled={!timeline.canGoBack}>Undo</button>
<button onClick={timeline.forward} disabled={!timeline.canGoForward}>Redo</button>
</>
);
}That's it. timeline.back() actually rolls text back to its previous value.
2. useTimelineReducer — for action-driven state
import { useTimelineReducer } from 'react-time-warp';
type Action = { type: 'add'; text: string } | { type: 'remove'; id: number };
function reducer(todos: Todo[], action: Action): Todo[] {
switch (action.type) {
case 'add': return [...todos, { id: Date.now(), text: action.text }];
case 'remove': return todos.filter((t) => t.id !== action.id);
}
}
const [todos, dispatch, timeline] = useTimelineReducer(reducer, []);
// timeline.history[i].label is the action.type that produced that snapshot
// — instant action-log debugger, scoped to this component.3. useTimeline — observational mode for advanced use
The low-level primitive. Records snapshots of whatever you give it; doesn't own the state.
const [text, setText] = useState('');
const timeline = useTimeline(text, {
onRewind: (newText) => setText(newText) // wire navigation back to setState
});Use this when you need to keep your existing state management but want history capture, or when you're integrating with non-useState state (refs, custom stores, external libraries).
Options
interface TimelineOptions<T, S = T> {
/** Max snapshots before oldest is evicted. Default: 50 */
maxHistory?: number;
/** Group rapid state changes into one snapshot. Default: 0ms (no debounce) */
debounce?: number;
/** Persist timeline to storage. Requires `key` to survive remounts. */
persist?: boolean;
/** Stable storage key suffix — REQUIRED for persist to actually work. */
key?: string;
/** Storage backing for persistence. Default: 'session'. */
storage?: 'session' | 'local' | StorageAdapter;
/** Per-snapshot label. Receives the SELECTED state (post-transform, post-select). */
label?: (state: S) => string;
/** Deep diff instead of shallow. Default: false */
useDeepDiff?: boolean;
/** Deep-clone snapshots to protect against mutation. Default: false */
clone?: boolean;
/** Shape-preserving transformation. Runs BEFORE select. Default: undefined */
transform?: (state: T) => T;
/** Project state to a derived shape. Snapshots store `S`, not `T`. */
select?: (state: T) => S;
/** Called on navigation — receives the SELECTED state, used to sync setState. */
onRewind?: (state: S, index: number) => void;
/** Security policy — see "Security" section below */
security?: SecurityOptions<S>;
}Security
When you persist state, ship it over the network as a bug report, or render
arbitrary user data, you need defensive controls. security gives you five
hooks — all run before diffing, never throw, and degrade silently in
production. Telemetry comes through onSecurityViolation.
interface SecurityOptions<T> {
/** Hard cap on snapshot size. Default: 512 KB. */
maxStateSize?: number;
/** Whitelist of allowed typeof values for top-level state fields.
* Class instances (Date/Map/RegExp/etc.) are always rejected when set. */
allowedTypes?: Array<'string' | 'number' | 'boolean' | 'object' | 'undefined'>;
/** Mask/strip sensitive fields before diffing or storage.
* Errors are caught and reported as 'sanitizeError' violations. */
sanitize?: (state: T) => T;
/** Custom sessionStorage key (max 64 chars, auto-prefixed with `rtw_`). */
sessionStorageKey?: string;
/** Structured telemetry sink. Errors thrown here are swallowed —
* bad telemetry must not crash state management. */
onSecurityViolation?: (reason: SecurityViolationReason) => void;
}
type SecurityViolationReason = {
rule: 'maxStateSize' | 'allowedTypes' | 'sanitizeError' | 'storageKey';
detail: string;
stateSnapshot?: unknown;
};Where the checks run
| Pipeline stage | Security enforced? |
|---|---|
| Initial snapshot (lazy init) | ✅ (violation deferred to post-mount) |
| Every state change | ✅ before diff |
| commit() (manual snapshot) | ✅ (manual ≠ unchecked) |
| clear() re-seed | ✅ |
| importReplay() | ✅ per-snapshot (drops invalid, clamps cursor) |
| persist restore from sessionStorage | ✅ per-snapshot (handles policy tightening across mounts) |
Recipes
Redact secrets before they ever hit the timeline
interface LoginForm {
email: string;
password: string;
totpCode: string;
}
const [form, setForm, timeline] = useTimelineState<LoginForm>(initial, {
security: {
sanitize: (s) => ({
...s,
password: '••••••••',
totpCode: s.totpCode ? '***' : ''
}),
onSecurityViolation: (r) => reportToSentry(r)
}
});
// Now exportReplay() can safely go to a bug tracker.Enforce JSON-safe state before persistence
// Reject Date, Map, custom classes — anything that won't round-trip through JSON
const [data, setData, timeline] = useTimelineState(initial, {
persist: true,
security: {
sessionStorageKey: 'editor-draft',
allowedTypes: ['string', 'number', 'boolean', 'object'],
onSecurityViolation: (r) => {
if (r.rule === 'allowedTypes') {
console.error('Refusing to persist non-serializable state:', r.detail);
}
}
}
});Hard cap memory growth
const [doc, setDoc, timeline] = useTimelineState(initialDoc, {
security: {
maxStateSize: 100 * 1024, // 100 KB per snapshot
onSecurityViolation: (r) => {
if (r.rule === 'maxStateSize') {
toast.warn('Document too large to undo past this point.');
}
}
}
});Validate replays from external sources
// Bug report comes in from an end user — could contain anything
async function loadReplay(reportId: string) {
const replay = await fetch(`/api/bug/${reportId}/replay`).then(r => r.json());
// importReplay() applies the current security policy to every snapshot.
// Invalid ones are dropped silently; violations fire onSecurityViolation.
timeline.importReplay(replay);
}What gets rejected vs. what's just masked
maxStateSizerejects the snapshot entirely. The next valid state still diffs against the last successfully-stored value (so a single oversized state in the middle doesn't poison the timeline).allowedTypesrejects the snapshot. Afunctionorsymbolvalue anywhere at the top level fails it, as does any class instance — includingDate,Map,Set,RegExp, and custom classes.sanitizemutates what's stored. Output replaces input; original state is never touched. If sanitize throws, the snapshot is rejected with rule:'sanitizeError'.sessionStorageKeyis enforced once at config time. Keys longer than 64 chars trigger'storageKey'and persistence silently disables.
Production behavior
- No
console.warn, no exceptions — only theonSecurityViolationcallback fires. - All console output is gated by
process.env.NODE_ENV === 'development'. - Bad telemetry callbacks (ones that throw) are isolated — they never reach the caller.
Inspecting recent violations
Every fired violation is also buffered (capped at 50, FIFO) and available via the timeline API:
const [state, setState, timeline] = useTimelineState(initial, { security: {...} });
// Reactive — re-renders when a new violation is buffered, stable when nothing changed
timeline.violations // readonly BufferedViolation[]
timeline.getRecentViolations(5) // most recent N (or all if omitted)
timeline.clearViolations() // empty the buffer (does not affect your callback)A BufferedViolation is just SecurityViolationReason & { timestamp: number }.
The buffer is independent of (and runs alongside) your onSecurityViolation callback —
your telemetry sink still fires for every violation; the buffer just gives you a local
view for UI and bug reports.
Standalone TimelineStore security
If you're using TimelineStore directly without useTimeline (custom integrations, tests),
pass a security policy to the constructor:
import { TimelineStore } from 'react-time-warp';
const store = new TimelineStore<MyState>(50, {
maxStateSize: 100 * 1024,
allowedTypes: ['string', 'number', 'boolean', 'object'],
onSecurityViolation: (r) => log(r)
});
store.push(validState); // returns true
store.push(invalidState); // returns false, fires the callback, store unchangedpush() now returns boolean (additive — existing void-returning calls still work).
Return Value (TimelineAPI)
interface TimelineAPI<T> {
history: readonly Snapshot<T>[]; // Stable reference between renders
currentIndex: number;
label: string;
canGoBack: boolean;
canGoForward: boolean;
// Navigation — all return the target state for chaining
rewindTo: (index: number) => T | undefined;
back: () => T | undefined;
forward: () => T | undefined;
// Manual control
commit: (label?: string) => void; // Force a snapshot (bypasses diff/debounce)
clear: () => void; // Reset, re-seed with current state
exportReplay: (metadata?) => SerializedReplay<T>; // JSON-serializable export
importReplay: (replay: SerializedReplay<T>) => void; // Restore from export
// Batching (v0.2)
batch: <R>(fn: () => R | Promise<R>) => R | Promise<R>;
// Security telemetry
violations: readonly BufferedViolation[]; // Last 50 buffered, stable reference
getRecentViolations: (limit?: number) => readonly BufferedViolation[];
clearViolations: () => void;
}The generic parameter shown as
There is the stored shape — i.e., the selected valueSwhen aselectoption is set, otherwise just the rawT.
All navigation methods (back, forward, rewindTo, commit, clear, exportReplay, importReplay) have stable references between renders — safe to use as useEffect deps.
history is also referentially stable until snapshots actually change.
Recipes
Replay export for bug reports
function BugReportButton({ timeline }) {
const handleReport = () => {
const replay = timeline.exportReplay({
userAgent: navigator.userAgent,
url: window.location.href,
timestamp: new Date().toISOString()
});
// Send to your bug tracker
fetch('/api/bug-report', {
method: 'POST',
body: JSON.stringify(replay)
});
};
return <button onClick={handleReport}>Send Bug Report</button>;
}Replay a recorded session in dev
function DevReplayer({ replayJson }) {
const [state, setState, timeline] = useTimelineState(initialState);
useEffect(() => {
if (replayJson) {
timeline.importReplay(JSON.parse(replayJson));
}
}, [replayJson]);
// Scrub through with the visual panel
return <TimelinePanel timeline={timeline} />;
}Snapshot only on save, not on every keystroke
const [draft, setDraft, timeline] = useTimelineState('');
// Disable automatic snapshotting via transform that always returns same value
// (or just use commit() manually)
const handleSave = () => {
timeline.commit(`Saved at ${new Date().toLocaleTimeString()}`);
saveToServer(draft);
};
return (
<>
<textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
<button onClick={handleSave}>Save (creates timeline checkpoint)</button>
</>
);Redact sensitive data before capture
interface FormState {
email: string;
password: string;
ssn: string;
}
const [form, setForm, timeline] = useTimelineState<FormState>(initialForm, {
transform: (s) => ({
...s,
password: '••••••',
ssn: s.ssn ? `xxx-xx-${s.ssn.slice(-4)}` : ''
})
});
// Now exportReplay() is safe to send to your error trackerProtect against state mutation
// If you mutate state directly (some libraries do), enable clone:
const [data, setData, timeline] = useTimelineState(initialData, { clone: true });
// Each snapshot now holds a deep copy — mutations to `data` don't corrupt history.Persist across page refreshes
// REQUIRED: provide a stable `key` — otherwise persistence is silently broken
const [text, setText, timeline] = useTimelineState('', {
persist: true,
key: 'editor-draft' // The key must be stable across mounts/refreshes
});Scoped keyboard shortcuts
const editorRef = useRef<HTMLDivElement>(null);
const [content, setContent, timeline] = useTimelineState('');
useUndoRedoShortcuts(timeline, {
target: editorRef.current, // Only fires inside this element
ignoreInInputs: false // Allow inside textareas (default)
});
return <div ref={editorRef} tabIndex={-1}>{/* ... */}</div>;Custom keyboard shortcuts
useUndoRedoShortcuts(timeline, {
matchUndo: (e) => e.altKey && e.key === 'Backspace',
matchRedo: (e) => e.altKey && e.shiftKey && e.key === 'Backspace'
});v0.2 features
Selectors — track only what matters
Big states have noise: cursor position, hover state, scroll offset, transient UI flags.
Snapshotting all of it bloats the timeline and forces undo to revert irrelevant fields.
select projects state to the slice you actually want history for:
interface Form {
// Tracked
title: string;
body: string;
// Noise
cursorPosition: number;
hoveredField: string | null;
scrollY: number;
}
const [form, setForm] = useState<Form>(initial);
const timeline = useTimeline(form, {
select: (s) => ({ title: s.title, body: s.body }),
onRewind: (slice) => setForm((prev) => ({ ...prev, ...slice }))
});The hook is polymorphic: useTimeline<T, S = T>(state: T): TimelineAPI<S>. Snapshots, diff,
security, exportReplay — everything operates on the selected S.
Cursor changes alone never create a new snapshot. Memory and visual noise both drop.
useTimelineStateanduseTimelineReducerdo not acceptselect— they ownsetStateand need snapshot shape to match state shape for the auto-rewind contract. Reach for plainuseTimeline+ manualonRewindif you need a projection.
Batching — collapse multiple changes into one snapshot
Without batching, three setState calls inside an async handler create three snapshots
(React only auto-batches sync code). timeline.batch(fn) solves this for both sync and async:
// Sync — same result as React 18 auto-batching, but explicit
timeline.batch(() => {
setName('Alice');
setEmail('[email protected]');
setRole('admin');
});
// Async — the actual reason this exists
await timeline.batch(async () => {
setLoading(true);
const result = await fetch('/api/save');
setData(await result.json());
setLoading(false);
});
// One snapshot, taken after all the awaits settle.Nested batches collapse to the outermost flush. Errors are re-thrown after batch state is
finalized, so a thrown fn doesn't leave the hook stuck in a suppressed state.
Round-trip changes inside a batch (e.g., setA(1); setA(0) when the prior state was 0)
produce no snapshot — the post-batch diff sees no change.
Diff view in the dev panel
<TimelinePanel /> now ships a JSON ↔ Diff toggle in the state-preview section.
Diff mode shows only what changed between snapshot N-1 and N — added/removed/changed
keys with old → new values. Color-coded chips (green added, red removed, amber changed):
✓ changed title "Draft" → "Published"
✓ removed draftId
✓ added publishedAt "2026-05-24T..."Disabled at index 0 (no prior snapshot). Off-the-rack — nothing to wire up.
Storage adapter — sessionStorage, localStorage, or your own
Choose where persist: true writes:
// Default — survives refresh, dies on tab close
useTimelineState(initial, { persist: true, key: 'draft' });
// Survives browser restarts
useTimelineState(initial, { persist: true, key: 'draft', storage: 'local' });
// Custom adapter — IndexedDB wrapper, in-memory, encrypted, etc.
const memoryAdapter: StorageAdapter = {
getItem: (k) => memoryStore.get(k) ?? null,
setItem: (k, v) => memoryStore.set(k, v),
removeItem: (k) => memoryStore.delete(k)
};
useTimelineState(initial, { persist: true, key: 'draft', storage: memoryAdapter });Resolved once at hook init. Same key in 'session' vs 'local' are independent —
not the same data. To migrate between storages, do an explicit exportReplay() →
swap options → importReplay().
Performance & Memory
| Concern | How we handle it |
|---|---|
| Memory growth | Circular buffer evicts oldest at maxHistory (default 50) |
| Diff cost | Shallow by default (O(keys)); deep is opt-in |
| Re-renders | Single revision counter; memoized derived values |
| Stable refs | Navigation methods + history array are referentially stable |
| Storage quota | Persistence fails gracefully when sessionStorage is full |
| SSR | All window/sessionStorage access is guarded |
When to enable clone: true: if any code mutates state after passing it to React (uncommon but happens with some libraries, especially when interfacing with imperative APIs).
When to enable useDeepDiff: true: if your state contains nested objects/arrays that change content but keep their references (e.g., direct mutations followed by setState(sameRef)).
When to use debounce: any time the state-driver fires many events per second (typing, dragging, scrolling). 250–500ms is a reasonable starting point.
SSR / Next.js
Safe by default. All browser API access is guarded:
// app/editor/page.tsx — works without 'use client' boundary noise
'use client';
import { useTimelineState } from 'react-time-warp';
export default function Editor() {
const [text, setText, timeline] = useTimelineState('', {
persist: true,
key: 'editor'
});
// sessionStorage is only touched in useEffect, never during render
return /* ... */;
}Comparison
| Feature | Redux DevTools | use-undoable | use-undo | react-time-warp |
|---|---|---|---|---|
| Zero setup | ❌ | ✅ | ✅ | ✅ |
| Works without a store | ❌ | ✅ | ✅ | ✅ |
| Actual undo/redo | ✅ | ⚠️ | ✅ | ✅ |
| Action-log debugger | ✅ | ❌ | ❌ | ✅ (via useTimelineReducer) |
| Visual timeline panel | ✅ | ❌ | ❌ | ✅ |
| Replay export/import | ✅ | ❌ | ❌ | ✅ |
| Persist across refresh | ❌ | ❌ | ❌ | ✅ |
| Keyboard shortcuts | manual | manual | manual | ✅ |
| TypeScript | ✅ | ✅ | ✅ | ✅ |
| Bundle (hooks only, gzip) | 150+ kB | ~2 kB | ~1 kB | 2.6 kB |
API Surface
// Hooks
import {
useTimeline, // Low-level observational
useTimelineState, // Recommended — owns state + timeline
useTimelineReducer, // Reducer-based state
useUndoRedoShortcuts // Cmd+Z / Cmd+Shift+Z wiring
} from 'react-time-warp';
// HOC
import { withTimeline } from 'react-time-warp';
// UI (dev-only, opt-in)
import { TimelinePanel } from 'react-time-warp';
// Primitives (advanced)
import { TimelineStore, shallowDiff, deepDiff, smartDiff } from 'react-time-warp';
// Types
import type {
TimelineAPI,
TimelineOptions,
Snapshot,
SerializedReplay,
SecurityOptions,
SecurityViolationReason,
SecurityRule,
AllowedType,
BufferedViolation,
WithTimelineProps,
TimelinePanelProps,
UndoRedoShortcutOptions
} from 'react-time-warp';Limitations
- Not a global state manager — One timeline per component. Use Redux/Zustand for app-wide state.
useTimelineReducerrewind is observational — The cursor moves but state isn't dispatched-back (reducers don't have inverse actions). Use the snapshot list to view prior states, not roll back via reducer.persist: truerequires a stablekey— Without it, data is written but never read. The library warns in dev.- Sync state only — For async/derived state, capture it after settling.
License
MIT © 2024 Vidhya Sagar Thakur
