Batteries-included undo/redo for Redux by tracking dispatched actions, enabling event-sourcing and replay capabilities.
- 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.
- 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.
npm install @ravanscafi/redux-undo-actionsimport { 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)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).
undoableActions(reducer, config?) => reducerpersistedUndoableActions(reducer, { ...config, persistence }) => { reducer, middleware }- ActionCreators:
undo(),redo(),reset(),hydrate(history),tracking( boolean)
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)
- trackedActions: string[]
- Which Redux action types to track in history.
- Default: [] (track all actions)
- 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())- reducerKey: string | false
- The key where your wrapped reducer lives in the root state.
- E.g.,
counterif your state iscombineReducers({ counter: reducer }).
- E.g.,
- Use
falseif the wrapped reducer is at the root.
- The key where your wrapped reducer lives in the root state.
- 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
loadingto done.
- Optional action type dispatched 100ms after hydration to help the UI
settle. E.g., to set
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.
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,
},
})- 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 trackingimport 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
}- 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.
- 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 that don’t change state (deepEqual) are ignored for history.
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.
Check the examples folder for more:
- Minimal counter example: examples/counter
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- Redux: >= 5 (peer dependency)
- TypeScript: >= 5 (dev setup)