Adding supports of undo/redo for the simple Todo list app

1. Introduction.

Most use-cases when the undo-redo feature is required are related to editors, whether it is a text editor or a graphics editor. For the sake of simplicity, I will use a simple todo based on Ngrx, but believe me, if you desire to add support of history in your application you will easily apply ideas from this article.

We will start our implementation, not from scratch. I have prepared the app that we will use to attach undo-redo there.

Here is a link to the repository: repository

Github pages

Here we have a basic Todo functionality :

add a new item update an existing item remove an item.

And two additional features:

Change the App theme Increase/Decrease the font size

All items data and settings (font, theme) are located in Ngrx Store.

Let’s define the requirements for implementing undo-redo-todo:

1. We want to undo/redo desired actions (add, update, remove, increase/decrease the font size), but don’t want to undo/redo changing the app theme.

2. We want to have shortcuts and buttons with tooltips for undo/redo.

The tooltip should describe what kind of action we undo/redo.

2. Definition

I considered two ways of undo/redo implementations:

Memorize the whole state on each action that can be undone. Create a chain of reverse actions.

Comparison of two ways

For this article, I decided to use the first way.

To implement undo/redo we need to use a middleware layer in Ngrx, that is represented by meta reducers.

As depicted in the picture when you dispatch an action this action is handled by a meta reducer.

Flow in Ngrx

Here is an example of a meta reducer structure. It is a high-order function.

3. Implementation

Firstly, generate the undo-redo module.

ng g m undo-redo

and create the required Ngrx components:

Actions (we need three actions: undo/redo/add action) Reducer — where we handle Selector And the most important one — Metareducer, place where we deal with the storing the current state.

Undoable state:

In order to have a consistent state that allows us to transfer back and forth.

We should have past states, future states, and the current state:

export interface UndoableState {

past: any[];

present: any;

future: any[];

}

Worth noting that it is not a state that we will use in Ngrx. It is required in the meta reducer.

Flow in case of performing an undoable/non-undoable operation

As depicted in the picture when we perform an undoable action(an action that can be undone/redone) meta reducer updates past states by adding the new state to the array and then it continues work as usual by moving through effects and the reducer.

Undoable action:

Before starting the implementation of the meta reducer let’s define the actions to available for undo and their structure.

export interface UndoableOperation {

type: string;

hint?: string;

}

The type will be used to track what kind of action should we undo. Hint field was defined for tooltip purposes.

Using the given interface we can define the operations:

export const UNDOABLE_OPERATIONS: { type: string, hint?: string }[] = [

{hint: 'Update todo', type: TodoActionTypes.UPDATE},

{hint: 'Add todo', type: TodoActionTypes.ADD},

{hint: 'Remove todo', type: TodoActionTypes.DELETE},

{hint: 'Decrease font size', type: SettingsActionTypes.DEC_FONT},

{hint: 'Increase font size', type: SettingsActionTypes.INC_FONT},

];

We intentionally didn’t specify the change theme action as we don’t want it to be undoable.

The last thing that we need to do before writing meta reducer is defining of states keys that need to persist. I will use describe them as paths to these keys.

const PERSISTENT_KEYS = ['todos', 'settings.fontSize'];

We have added todos as we want to store this sub-state completely. From settings, we need only to keep font size.

Meta Reducer

Now as we ready to write our meta reducer let’s start.

At first, we init our undoable states object. Initially, we don’t have past states and future states but we have a parent that is defined by calling Init action.

ActionReducer<any> {

let states: UndoableState = {

past: [],

present: reducer(undefined, {type: '__INIT__'}),

future: []

};

Meta reducer is a higher-order function so we need to return a function:

return (state, action) => {

const {past, present, future} = states;

//... }

Then we define the way how we handle incoming action:

What do we have here?

In case when the action is not of type undo/redo, we check that it is in the Undoable operations list and if so we perform this action and store result to the present state and also we update the past state's list by appending past state. We extract the state using the extractState method. As you remember we have defined Persistent keys array and we use it to mitigate storing unnecessary fields in our states. For the sake of simplicity I use the lodash function

function extractState(state: AppState) {

return pick(state, PERSISTENT_KEYS);

}

3. We execute the Add action at the end, just to have tooltips for undo/redo buttons. We pass there either action’s hint or action’s type if the hint is not specified.

4. If action is not an undoable we just update present state in order to keep it consistent.

Let’s write undo and redo handlers:

It is quite straightforward:

For undo, we just update past states by taking the last item and place it to the present. We unshift the current present to the future states. For redo we do vice-versa

Worth noting that we use merge states because we store our states partially.

function mergeStates(state: AppState, undoablePart) {

const newState = cloneDeep(state);

PERSISTENT_KEYS.forEach(key => set(newState, key, get(undoablePart, key)));

return newState;

}

We also leverage get and set from Lodash in order to keep it simple.

Let’s recap what we have finally in our meta-reducer:

undo-redo.meta.ts

4. First use

Let’s update our todo app by adding shortcuts for undo(ctrl-z) and for redo(ctrl-y)

We need to add minor changes in app.component:

@HostListener('window:keydown', ['$event']) keyDown(e: KeyboardEvent) {

if (e.ctrlKey && e.code === 'KeyZ') {

this.store.dispatch(new UndoAction());

} else if (e.ctrlKey && e.code === 'KeyY') {

this.store.dispatch(new RedoAction());

}

}

But it is not enough. We also need to enable meta reducers in our app.

StoreModule.forRoot(appReducer, {metaReducers: [undoredoMeta]})

Now if we run our application we can try to undo/redo actions. Everything works perfectly.

4. Second use

Let’s add buttons for undo and redo and add tooltips for them.

Note: they should be disabled if there are no actions to undo/redo.

Currently, we only work with meta reducer and there is no way to track what actions do we have to undo/redo. But we have already implemented actions for undo/redo, so let’s reuse them and define the state:

export interface UndoredoState {

undoActions: string[];

redoActions: string[];

}

We will store only tooltips here. And in case if there are no elements in the array we cannot undo/redo.

Let’s write a reducer for undo-redo:

On add action, we just update undoActions and clear redoActions.

On redo action, we update undo actions and unshift the first item.

On undoing action we update redo actions and pop the last item.

Let’s add a selector to extract undo/redo actions:

import {UndoredoState} from './undo-redo-state';

import {createSelector} from '@ngrx/store';



const selectUndoredoState = (state: any) => state.undoredo;



export const getUndoAction = createSelector(

selectUndoredoState,

(state: UndoredoState) => state.undoActions.length > 0

&& state.undoActions[state.undoActions.length - 1]

);

export const getRedoAction = createSelector(

selectUndoredoState,

(state: UndoredoState) => state.redoActions.length > 0

&& state.redoActions[0]

);

Finally, we need to register a reducer in the undo-redo module:

StoreModule.forFeature('undoredo', undoredoReducer)

And import this module in app.module

imports: [

//...

UndoRedoModule

],

Now we can easily add buttons for undo/redo:

<mat-icon class="icon" [class.disabled]="!undoItem" [matTooltip]="undoItem" matRipple (click)="undo()" matRippleUnbounded="unbounded">

undo

</mat-icon>

<mat-icon class="icon" [class.disabled]="!redoItem" [matTooltip]="redoItem" matRipple (click)="redo()" matRippleUnbounded="unbounded">

redo

</mat-icon>

To extract undoItem and redoItem we’ll use selectors:

this.store.pipe(select(getUndoAction), takeUntil(this.unsubscribe$)).subscribe(undoItem => {

this.undoItem = undoItem;

});

this.store.pipe(select(getRedoAction), takeUntil(this.unsubscribe$)).subscribe(redoItem => {

this.redoItem = redoItem;

});

That is. You can undo/redo using buttons and if you mouse over them you can see tooltips with correspond information.

Finally

Thank you for reading this article. I hope it was useful. Don’t hesitate to ask questions and leave comments.

Repository with the final version: link

Github pages

BTW: leave in comments the way how can we add the undo support for SettingsActionTypes.CHANGE_THEME?