Skip to content

Repository files navigation

@ravanscafi/redux-undo-actions

Batteries-included undo/redo for Redux by tracking dispatched actions, enabling event-sourcing and replay capabilities.

Features

  • Undo/Redo: Add undo/redo to any Redux reducer.
  • Action History: Tracks dispatched actions instead of state changes.
  • Persistence: Optional middleware for persisting history (e.g., to localStorage or AsyncStorage).
  • TypeScript Support: Strict types for all APIs.

Why this library?

  • Event-sourcing approach: store actions, not bulky state snapshots.
  • Precise control: track and undo only the action types you care about.
  • First-class persistence.
  • Works with any reducer; minimal API surface.

Installation

npm install @ravanscafi/redux-undo-actions

Breaking changes in 0.2

Version 0.2 uses reference equality to identify actions that did not change state. Reducers must follow the standard Redux convention of returning the existing state object when an action has no effect. Returning a new object with equal values is treated as a state change and the action is added to history.

Breaking changes in 0.1

Version 0.1 makes stored history immutable by default. Tracked actions, hydrated histories, and preloaded Redux history state are copied and deeply frozen. The public history types are readonly, and stored actions are copies rather than the original objects passed to dispatch.

With the default immutableHistory: true, tracked actions must contain only primitives, arrays, and plain objects with data properties. Class instances (including Date, Map, and Set), functions, and accessor properties are rejected with a path to the unsupported value. Convert those values to plain data when possible. Otherwise, set immutableHistory: false; actions will be stored by reference and Redux Toolkit will inspect the full history on each dispatch in development.

Persistence still uses JSON. Cycles, bigint, symbol-valued properties, and other values unsupported by JSON.stringify must not be used in persisted histories, even though non-persisted immutable history can preserve cycles, shared references, and symbol-keyed data.

Quick Start

import { ActionCreators, undoableActions } from '@ravanscafi/redux-undo-actions'
import { combineReducers, createStore } from 'redux'

function counterReducer(state = 0, action) {
  switch (action.type) {
    case 'counter/increment':
      return state + 1
    case 'counter/decrement':
      return state - 1
    default:
      return state
  }
}

const rootReducer = combineReducers({
  // Wrap your reducer with undoableActions
  counter: undoableActions(counterReducer),
})

const store = createStore(rootReducer)

store.dispatch({ type: 'counter/increment' })
store.dispatch(ActionCreators.undo())
store.dispatch(ActionCreators.redo())

// Access current state via `present`
console.log(store.getState().counter.present)
// Check if undo/redo is possible via `canUndo` and `canRedo`. Useful to enable/disable buttons.
console.log(store.getState().counter.canUndo)
console.log(store.getState().counter.canRedo)

Redux Toolkit examples

Basic usage:

import { configureStore } from '@reduxjs/toolkit'
import { undoableActions } from '@ravanscafi/redux-undo-actions'
import counterSlice from './counterSlice'

// Basic
const store = configureStore({
  reducer: {
    counter: undoableActions(counterSlice.reducer, {
      trackedActions: ['counter/increment', 'counter/decrement'],
    }),
  },
})

With persistence:

import { configureStore } from '@reduxjs/toolkit'
import { persistedUndoableActions } from '@ravanscafi/redux-undo-actions'
import counterSlice from './counterSlice'

// Persisted
const { reducer, middleware } = persistedUndoableActions(counterSlice.reducer, {
  trackedActions: ['counter/increment', 'counter/decrement'],
  persistence: {
    reducerKey: 'counter',
    getStorageKey: () => 'my-app-counter',
    storage: {
      getItem: async (key: string): Promise<string | null> =>
        localStorage.getItem(k),
      setItem: async (key: string, value: string): Promise<void> =>
        localStorage.setItem(k, v),
      removeItem: async (key: string): Promise<void> =>
        localStorage.removeItem(k),
    },
  },
})

const persistedStore = configureStore({
  reducer: { counter: reducer },
  // Put async middlewares first; persistence middleware last
  middleware: (getDefault) => getDefault().concat(middleware),
})

Tip: For larger histories, consider compressing the string using a library like lz-string (compressToUTF16/ decompressFromUTF16).

API

  • undoableActions(reducer, config?) => reducer
  • persistedUndoableActions(reducer, { ...config, persistence }) => { reducer, middleware }
  • ActionCreators: undo(), redo(), reset(), hydrate(history), tracking( boolean)

State shape (HistoryState)

Each wrapped reducer exposes the following shape:

  • present: current state
  • canUndo: boolean
  • canRedo: boolean
  • internal history (not for public use):
    • actions: tracked actions
    • snapshot: state at the point where history tracking started
    • tracking: boolean (whether to track new actions)

Configuration (UndoableActionsConfig)

  • trackedActions: string[]
    • Which Redux action types to track in history.
    • Default: [] (track all actions)
  • immutableHistory?: boolean
    • Copies and deeply freezes tracked actions before storing them.
    • Default: true.
    • Set to false if an action contains non-plain values.
  • undoableActions: string[]
    • Which tracked actions are undoable/redone.
    • Default: [] (all tracked actions are undoable)
    • Note: Non-undoable tracked actions still update the state but do not clear the redo stack.
  • trackAfterAction?: string
    • If provided, history tracking starts only after this action is processed.
    • Useful when your initial state loads asynchronously.
  • internalActions: { undo, redo, reset, hydrate, tracking }
    • Override internal action types to avoid collisions when using multiple instances.
    • If you override these, the built-in ActionCreators no longer match; dispatch your custom types manually.

Multiple instances tip:

  • If you wrap multiple reducers, give each instance unique internalActions to avoid dispatching undo/redo to all of them at once.

Example with custom internal actions:

import { ActionCreators } from './actions'

const reducer = undoableActions(counterReducer, {
  trackedActions: ['counter/increment', 'counter/decrement'],
  undoableActions: ['counter/increment'],
  internalActions: {
    undo: 'counter/undo',
    redo: 'counter/redo',
    reset: 'counter/reset',
    hydrate: 'counter/hydrate',
    tracking: 'counter/tracking',
  },
})

// undo will work:
store.dispatch({ type: 'counter/undo' })

// undo will NOT work (wrong type):
store.dispatch(ActionCreators.undo())

Persistence (Persistence)

  • reducerKey: string | false
    • The key where your wrapped reducer lives in the root state.
      • E.g., counter if your state is combineReducers({ counter: reducer }).
    • Use false if the wrapped reducer is at the root.
  • getStorageKey(getState): string | false
    • Return a unique key for storage, or false to skip persistence (e.g., no active document).
    • Example: derive by entity/document ID from state.
  • storage: StoragePersistor
    • Async interface for getItem, setItem, removeItem that read/write strings.
  • dispatchAfterMaybeLoading?: string
    • Optional action type dispatched 100ms after hydration to help the UI settle. E.g., to set loading to done.

Middleware order:

  • Put async/side-effect middlewares (thunk/saga/observable) before this persistence middleware so only plain actions reach it.

What gets saved: a JSON string with { actions, tracking } (ExportedHistory). Reset removes saved history.

Load behavior: when trackAfterAction is seen, the middleware tries to load history using getStorageKey; if found, it dispatches hydrate with the saved data, then optionally dispatchAfterMaybeLoading. If trackAfterAction is not set, loading happens immediately on init.

Storage operations are processed in dispatch order within each middleware instance. Pending writes do not cause later saves, resets, or loads to be dropped. If the storage key changes while history is loading, that stale history is ignored instead of being applied to the new state.

Example: conditional persistence per entity/document

const { reducer, middleware } = persistedUndoableActions(reducer, {
  persistence: {
    reducerKey: 'editor',
    getStorageKey: (getState) => {
      const { activeDocId } = getState() as { activeDocId?: string }
      return activeDocId ? `history_${activeDocId}` : false
    },
    storage: myPersistor,
  },
})

Hydration and tracking

  • Hydrate existing history manually:
import { ActionCreators } from '@ravanscafi/redux-undo-actions'

store.dispatch(
  ActionCreators.hydrate({
    actions: [{ action: { type: 'counter/increment' }, undone: false }],
    tracking: true,
  }),
)
store.dispatch(ActionCreators.tracking(false)) // disable tracking
store.dispatch(ActionCreators.tracking(true)) // re-enable tracking

Selectors (TypeScript)

import type { HistoryState } from '@ravanscafi/redux-undo-actions'
import { UnknownAction } from 'redux'

interface RootState {
  counter: HistoryState<number, UnknownAction>
}

const selectCounter = (s: RootState) => {
  s.counter.present
}
const selectCanUndo = (s: RootState) => {
  s.counter.canUndo
}
const selectCanRedo = (s: RootState) => {
  s.counter.canRedo
}

How it works

  • Snapshot and tracking
    • On init, snapshot = reducer(undefined, {}), tracking = trackAfterAction === undefined.
    • If trackAfterAction is set, the first time that action is handled, tracking is enabled and snapshot becomes the state after that action.
  • Action-based history
    • The library stores a list of actions with an undone flag, not past/present/future state snapshots.
    • Tracked actions are copied and deeply frozen. Later changes to the object passed to dispatch do not affect replay, and immutable-state middleware can skip the frozen history.
    • History actions support primitives, arrays, and plain objects. Class instances, functions, and accessor properties are rejected because they cannot be copied safely for replay.
    • present is always computed by your reducer; on undo/redo, we either replay from snapshot or apply a minimal step when possible.
  • Undo/Redo semantics
    • Undo marks the most recent undoable tracked action as undone and recomputes present.
    • Redo flips the first undone undoable action back; if it’s the last entry, we apply the reducer once; otherwise we replay.
    • New undoable actions clear future (redo) actions; non-undoable tracked actions do not clear redo.
  • Reset and hydrate
    • Reset clears history; with trackAfterAction set, it restores to the snapshot right after that action.
    • Hydrate replaces actions and tracking, sets snapshot to the pre-hydration present, and replays to compute the new present.
  • Guardrails
    • trackedActions and undoableActions default to [] which means “all”.
    • No-op actions are not recorded in history when the reducer returns the existing state reference.

Comparison with redux-undo

  • Model
    • redux-undo: stores past/present/future state snapshots.
    • This library: stores action history (event-sourcing style) plus a snapshot, and replays actions to derive state.
  • Memory and payloads
    • For large states, storing actions is typically smaller than storing full snapshots; persistence can save just the actions.
    • On the other hand, replaying actions can be slower than switching snapshots, especially with long histories.
  • Control and filtering
    • trackedActions/undoableActions let you scope both what is recorded and what is undoable at the action-type level.
    • Redo is cleared only by new undoable actions; non-undoable actions won’t wipe redo. Tracked actions that are not undoable still update state, even if they happen after the undone action.
  • Bootstrapping and hydration
    • trackAfterAction lets you defer history until initial data loads; built-in hydrate action and persistence middleware make reload/restore straightforward.

Both approaches are valid; choose based on whether you prefer state snapshots ( redux-undo) or action/event history (this library). Also, test for performance.

Examples

Check the examples folder for more:

To run the examples locally, make sure to first build the base package:

npm install
npm run build
# open the example code and follow the instructions in their README

Compatibility

  • Redux: >= 5 (peer dependency)
  • TypeScript: >= 5 (dev setup)

License

MIT

About

Batteries-included undo/redo for Redux by tracking dispatched actions, enabling event-sourcing and replay capabilities.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages