history-adapter
TypeScript icon, indicating that this package has built-in type declarations

1.2.0 • Public • Published

History Adapter

A "history adapter" for managing undoable (and redoable) state changes with immer.

Includes generic methods along with Redux-specific helpers.

History state

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

Generic helper methods

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.

getInitialState

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>

undoable

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.

Extracting whether an action is undoable

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 },
);

Selecting history state

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 }).

undo, redo, jump, clearHistory

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.

Pausing history

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.

Redux helper methods

If imported from "history-adapter/redux", the history adapter will have additional methods to assist use with Redux, specifically with Redux Toolkit.

undoableReducer

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 },
      ),
    },
  },
});

withoutPayload

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

withPayload

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);
        },
      ),
    },
  },
});

getSelectors

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 })); // []

Configuration

Optionally, createHistoryAdapter accepts a configuration object with some of the following options:

const booksHistoryAdapter = createHistoryAdapter({
  limit: 5,
});

limit

Defines a maximum history size.

Credits

Inspired by createEntityAdapter, and code posted in a discussion with @medihack.

Package Sidebar

Install

npm i history-adapter

Weekly Downloads

7

Version

1.2.0

License

MIT

Unpacked Size

84 kB

Total Files

20

Last publish

Collaborators

  • eskimojo