npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

react-time-warp

v0.2.0

Published

Zero-config state DVR for React — record, rewind and replay component state

Downloads

309

Readme

react-time-warp 🕰️

Zero-config state DVR for React components — record, rewind, and replay state history at runtime.

status tests size license

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-shakeablesideEffects: 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/redouseTimelineState actually mutates state on rewind
Replay export/import — JSON-serializable timelines for bug reports
Reducer supportuseTimelineReducer for action-driven state
Keyboard shortcuts — Opt-in Cmd+Z / Cmd+Shift+Z hook
Storage choicesessionStorage, 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
Batchingtimeline.batch(fn) collapses multiple setState calls into one snapshot (sync + async)

Installation

npm install react-time-warp

Quick 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

  • maxStateSize rejects 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).
  • allowedTypes rejects the snapshot. A function or symbol value anywhere at the top level fails it, as does any class instance — including Date, Map, Set, RegExp, and custom classes.
  • sanitize mutates what's stored. Output replaces input; original state is never touched. If sanitize throws, the snapshot is rejected with rule: 'sanitizeError'.
  • sessionStorageKey is enforced once at config time. Keys longer than 64 chars trigger 'storageKey' and persistence silently disables.

Production behavior

  • No console.warn, no exceptions — only the onSecurityViolation callback 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 unchanged

push() 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 T here is the stored shape — i.e., the selected value S when a select option is set, otherwise just the raw T.

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 tracker

Protect 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.

useTimelineState and useTimelineReducer do not accept select — they own setState and need snapshot shape to match state shape for the auto-rewind contract. Reach for plain useTimeline + manual onRewind if 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.
  • useTimelineReducer rewind 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: true requires a stable key — 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