Home

Awesome

Effector History

Utility library that implements undo/redo feature for you

Installation

pnpm add effector-history

Usage

import { createHistory } from "effector-history";

const right = createEvent();
const up = createEvent();

const $x = createStore(0);
const $y = createStore(0);

$x.on(right, (x) => x + 1);

$y.on(up, (y) => y + 1);

const history = createHistory({
  source: {
    x: $x,
    y: $y,
  },
  maxLength: 20,
});

combine([$x, $y]).watch(console.log);

up(); // [0, 1]
up(); // [0, 2]
right(); // [1, 2]
up(); // [1, 3]

history.undo(); // [1, 2]
history.undo(); // [0, 2]
history.redo(); // [1, 2]

Customization

clock property

Sometimes you need to add records only on a specific event.
For example, you have draggable blocks, and you want to push to history only whenever drag is ended.
For such cases you can use clock property:

createHistory({
  source: {
    x: $x,
    y: $y,
  },
  // Only these events will trigger history
  clock: [dragEnded],
  maxLength: 20,
});

Keep in mind that store updates won't push to history in this case, so it can cause data incosistency

serialize property

If you don't want to serialize history (e.g. you store custom instances and use SSR), you can add serialize property:

createHistory({
  source: {
    socketInstance: $socketInstance,
  },
  serialize: "ignore",
});

Strategies

Imagine that you have a custom editor with its own history logic. And you want to treat typing in text field as a single history record.

You can setup custom strategy for a certain clock

import { createHistory, replaceRepetitiveStrategy } from "effector-history";

const history = createHistory({
  source: {
    text: $text,
    attachments: $attachments,
  },
  clock: [textChanged, attachmentAdded, attachmentRemoved],
  strategies: new Map().set(textChanged, replaceRepetitiveStrategy),
});

attachmentAdded(file);
textChanged("f");
textChanged("fo");
textChanged("foo");

history.$history.getState();
/* 
  [
    { text: '', file: null },
    { text: '', file: file },
    { text: 'foo', file: file } // <- Batched in one
  ]
*/

Built-in strategies:

If you want to make a custom strategy, you can use customStrategy helper

import { createHistory, customStrategy } from "effector-history";

const buttonTextChanged = createEvent<{ idx: number; text: string }>();

// List of parameters
// trigger - Unit that caused history trigger
// payload - Payload of `trigger`
// curTrigger - Unit that caused currently active history record
// curPayload - Payload of `curTrigger`
// curRecord - Currently active history record
const buttonTextChangedStrategy = customStrategy<{ idx: number; text: string }>({
  check: ({ trigger, curTrigger, payload, curPayload }) => {
    // If trigger is different, push a new record
    if (trigger !== curTrigger) {
      return "push";
    }
    // If changing different button, push a new record
    if (payload.idx !== curPayload.idx) {
      return "push";
    }
    // Otherwise replace the current one
    return "replace";
  },
});

const history = createHistory({
  source: {
    buttons: $buttons,
  },
  clock: [buttonTextChanged],
  strategies: new Map().set(butonTextChanged, buttonTextChangedStrategy),
});

If you want to merge multiple maps of strategies, you can use mergeStrategiesMaps helper:

import { pushStrategy, skipDuplicatesStrategy, mergeStrategiesMaps } from "effector-history";

const textFieldStrategies = new Map().set(textFieldBlurred, skipDuplicatesStrategy);

const buttonsStrategies = new Map().set(buttonAdded, pushStrategy).set(buttonRemoved, pushStrategy);

const optionsStrategy = mergeStrategiesMaps([textFieldStrategies, buttonsStrategies]);

API Reference

history.$history; // All history records
history.$canUndo; // `true` if can undo, `false` otherwise
history.$canRedo; // `true` if can redo, `false` otherwise
history.$curIndex; // Index of currently active history index
history.$curRecord; // Current history record
history.$length; // Amount of records in history (min. 1)

history.undo(); // Undo
history.redo(); // Redo
history.clear(); // Clear history
history.push(data); // Manually push something to history
history.replace(data); // Manually replace something in history

TODO