A "history adapter" for managing undoable (and redoable) state changes with immer.
Includes generic methods along with Redux-specific helpers.
A history state shape looks like:
export interface PatchState {
undo: Array<Patch>;
redo: Array<Patch>;
}
export interface HistoryState<Data> {
past: Array<PatchState>;
present: Data;
future: Array<PatchState>;
}
The current data is stored under the present
key, and changes are stored as collections of JSON Patches (created by immer).
To access the helper methods, create an adapter instance with a specific data type:
import { createHistoryAdapter } from "history-adapter";
interface Book {
id: number;
title: string;
}
const booksHistoryAdapter = createHistoryAdapter<Array<Book>>();
You can then use the methods attached to manage state as required.
This method takes the data as specified during creation, and wraps it in a clean history state shape.
const initialState = booksHistoryAdapter.getInitialState([]);
// ^? HistoryState<Array<Book>>
A data-agnostic version of this is exported separately.
import { getInitialState } from "history-adapter";
const initialState = getInitialState(book);
// ^? HistoryState<Book>
The undoable
method wraps an immer recipe which operates on the data type, and returns a function which automatically updates the history state with the changes.
const addBook = booksHistoryAdapter.undoable((books, book: Book) => {
books.push(book);
});
const nextState = addBook(initialState, { id: 1, title: "Dune" });
console.log(initialState.present, nextState.present); // [] [{ id: 1, title: "Dune" }]
Because immer wraps the values, you can use mutating methods without the original data being affected.
If passed an immer draft, the returned function will act mutably, otherwise it'll act immutably and return a new state.
If wrapped in undoable
, an action is assumed to be undoable, and its changes will be included in the history.
For finer control over this, an isUndoable
predicate can be passed as part of the second argument to undoable
. It receives the same arguments as the recipe (except the current state) and should return false
if the action is not undoable. true
or undefined
will default to the action being undoable.
const addBook = booksHistoryAdapter.undoable(
(books, book: Book, undoable?: boolean) => {
books.push(book);
},
{ isUndoable: (book, undoable) => undoable },
);
Sometimes the history state needs to be selected from a larger state object. In this case, the selectHistoryState
config option can be used.
const addBook = booksHistoryAdapter.undoable(
(books, book: Book, undoable?: boolean) => {
books.push(book);
},
{
selectHistoryState: (state: RootState) => state.books,
},
);
It should be a function which receives the wider state and returns the history state shape ({ past, present, future }
).
The undo
, redo
and jump
methods use the stored patches to move back/forward in the change history.
const undoneState = booksHistoryAdapter.undo(nextState);
console.log(undoneState.present); // []
const redoneState = booksHistoryAdapter.redo(undoneState);
console.log(undoneState.present); // [{ id: 1, title: "Dune" }]
// jump(-2) is like calling undo() twice and jump(2) is like calling redo() twice
const jumpedState = booksHistoryAdapter.jump(redoneState, -1);
console.log(jumpedState.present); // []
clearHistory
resets the history of the state while leaving the current value.
const resetState = booksHistoryAdapter.clearHistory(redoneState);
console.log(resetState.present); // [{ id: 1, title: "Dune" }]
// history has been cleared, so cannot be undone
const tryUndoState = booksHistoryAdapter.undo(resetState);
console.log(tryUndoState.present); // [{ id: 1, title: "Dune" }]
Just like undoable functions, these methods will act mutably when passed an immer draft and immutably otherwise.
If you need to make changes to the state without affecting the history, you can use the pause
and resume
methods.
const pausedState = booksHistoryAdapter.pause(resetState);
const withBook = addBook(pausedState, { id: 2, title: "Foundation" });
const resumedState = booksHistoryAdapter.resume(withBook);
const undoneState = booksHistoryAdapter.undo(resumedState);
// changes while paused cannot be undone
console.log(undoneState.present); // [{ id: 1, title: "Dune" }, { id: 2, title: "Foundation" }]
Changes will still be made to the data while paused (including undo
and redo
), but they won't be recorded in the history.
If imported from "history-adapter/redux"
, the history adapter will have additional methods to assist use with Redux, specifically with Redux Toolkit.
Similar to undoable
, but only allows for a single action
argument, and automatically extracts whether an action was undoable from its action.meta.undoable
value.
import { createHistoryAdapter } from "history-adapter/redux";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
const booksHistoryAdapter = createHistoryAdapter<Books>();
const booksSlice = createSlice({
name: "books",
initialState: booksHistoryAdapter.getInitialState([]),
reducers: {
addBook: {
prepare: (book: Book, undoable?: boolean) => ({
payload: book,
meta: { undoable },
}),
reducer: booksHistoryAdapter.undoableReducer(
(state, action: PayloadAction<Book>) => {
state.push(action.payload);
},
),
},
},
});
It can accept a configuration object as the second argument, with the same options as undoable
(except isUndoable
).
const initialState = { books: booksHistoryAdapter.getInitialState([]) };
const booksSlice = createSlice({
name: "books",
initialState,
reducers: {
addBook: {
prepare: (book: Book, undoable?: boolean) => ({
payload: book,
meta: { undoable },
}),
reducer: booksHistoryAdapter.undoableReducer(
(state, action: PayloadAction<Book>) => {
state.push(action.payload);
},
{ selectHistoryState: (state: typeof initialState) => state.books },
),
},
},
});
Creates a prepare callback which has one optional argument, undoable
. This ensures it results in action.meta.undoable
being the correct value for undoableReducer
.
const booksSlice = createSlice({
name: "books",
initialState: booksHistoryAdapter.getInitialState([]),
reducers: {
removeLastBook: {
prepare: booksHistoryAdapter.withoutPayload(),
reducer: booksHistoryAdapter.undoableReducer((state) => {
state.pop();
}),
},
},
});
dispatch(removeLastBook()); // action.meta.undoable === undefined (same as true)
dispatch(removeLastBook(true)); // action.meta.undoable === true
dispatch(removeLastBook(false)); // action.meta.undoable === false
Creates a prepare callback which receives two arguments, a specified payload and an optional undoable
value.
const booksSlice = createSlice({
name: "books",
initialState: booksHistoryAdapter.getInitialState([]),
reducers: {
addBook: {
prepare: booksHistoryAdapter.withPayload<Book>(),
reducer: booksHistoryAdapter.undoableReducer(
(state, action: PayloadAction<Book>) => {
state.push(action.payload);
},
),
},
},
});
dispatch(addBook(book)); // action.meta.undoable === undefined (same as true)
dispatch(addBook(book, true)); // action.meta.undoable === true
dispatch(addBook(book, false)); // action.meta.undoable === false
As a tip, undo
, redo
, pause
, resume
and clearHistory
are all valid reducers due to not needing an argument. The version of jump
on a Redux history adapter allows for either a number or payload action, making it also valid.
const booksSlice = createSlice({
name: "books",
initialState: booksHistoryAdapter.getInitialState([]),
reducers: {
undo: booksHistoryAdapter.undo,
redo: booksHistoryAdapter.redo,
jump: booksHistoryAdapter.jump,
pause: booksHistoryAdapter.pause,
resume: booksHistoryAdapter.resume,
clearHistory: booksHistoryAdapter.clearHistory,
addBook: {
prepare: booksHistoryAdapter.withPayload<Book>(),
reducer: booksHistoryAdapter.undoableReducer(
(state, action: PayloadAction<Book>) => {
state.push(action.payload);
},
),
},
},
});
A method which returns some useful selectors.
const { selectCanUndo, selectCanRedo, selectPresent, selectPaused } =
booksHistoryAdapter.getSelectors();
console.log(
selectPresent(initialState), // []
selectCanUndo(initialState), // false
selectCanRedo(initialState), // false
selectPaused(initialState), // false
);
console.log(
selectPresent(nextState), // [{ id: 1, title: "Dune" }]
selectCanUndo(nextState), // true
selectCanRedo(nextState), // false
selectPaused(nextState), // false
);
console.log(selectPaused(pausedState)); // true
If an input selector is provided, the selectors will be combined using reselect.
const { selectPresent } = booksHistoryAdapter.getSelectors(
(state: RootState) => state.books,
);
console.log(selectPresent({ books: initialState })); // []
The instance of createSelector
used can be customised, and defaults to RTK's createDraftSafeSelector
:
import { createSelectorCreator, lruMemoize } from "reselect";
const createLruSelector = createSelectorCreator(lruMemoize);
const { selectPresent } = booksHistoryAdapter.getSelectors(
(state: RootState) => state.books,
{ createSelector: createLruSelector },
);
console.log(selectPresent({ books: initialState })); // []
Optionally, createHistoryAdapter
accepts a configuration object with some of the following options:
const booksHistoryAdapter = createHistoryAdapter({
limit: 5,
});
Defines a maximum history size.
Inspired by createEntityAdapter, and code posted in a discussion with @medihack.