How to proper implement Undo Redo system in React using context and command pattern?

940 Views Asked by At

I'm trying to implement an UndoRedo system in my App. I'm using TypeScript and React. I want to use the command pattern to develop the system as I want it to be a global system for several commands aside the App, providing it trough useContext hook, this way, all my components can access the UndoRedo methods.

I already tryed some kind of implementation but I'm struggling with hooks and rendering.

For now, what I have is something like this (simplified):

import { useState } from 'react'

interface Command { executeFn: Function, undoFn: Function }

interface State { history: Command[], currentIndex: number }

export function useUndo() {
    const [state, setState] = useState<State>({ history: [], currentIndex: 0 })

    const execute = (executeFn: Function, undoFn: Function) => {
        const cmd: Command = { executeFn, undoFn } // creates a command object
        setState(prevState => {
            const { history, currentIndex } = prevState // extracts the history from the state object
            cmd.executeFn() // executes the executeFn from command
            return { history: [...history, cmd], currentIndex: currentIndex + 1 } // returns the new state
        })
    }

    const undo = () => {
        setState(prevState => {
            const { history, currentIndex } = prevState // extracts the history from
            if (currentIndex <= 0) return prevState // doesn't undo if there is no history backwards
            const lastCmd = history[currentIndex - 1]
            lastCmd.undoFn() // executes the undoFn from command
            return { history, currentIndex: currentIndex - 1 } // returns the new state
        })
    }

    const redo = () => {
        setState(prevState => {
            const { history, currentIndex } = prevState // extracts the history from
            if (currentIndex >= history.length - 1) return prevState // doesn't redo if there is no history forward
            const nextCmd = history[currentIndex + 1]
            nextCmd.executeFn() // executes the executeFn from command
            return { history, currentIndex: currentIndex + 1 }
        })
    }

    const canUndo = state.currentIndex > 0
    const canRedo = state.currentIndex < history.length - 1

    return { execute, undo, redo, canUndo, canRedo }
}

I'm providing this trough a context provider:

import { createContext } from 'react'
import { useUndo } from './UndoRedo.tsx' // the file above

const UndoRedoContext = createContext({})

export const UndoRedoProvider = (props: React.PropsWithChildren) => {
    const undoRedo = useUndo();

    return (
        <UndoRedoContext.Provider value={undoRedo}>
            {props.children}
        </UndoRedoContext.Provider>
    );
};

export const useUndoRedoContext = () => useContext(UndoRedoContext);

Then I have a simple Component for testing purposes:

import { useState } from 'react'
import { useUndoRedoContext } from '../context/undoRedoContext'

export function EditUser() {
    const undoRedo = useUndoRedoContext()
    const [nameInput, setNameInput] = useState('')
    const handleInputChange = (e: React.FormEvent) => {
        e.preventDefault()
        const cNameInput = nameInput
        undoRedo.execute(
            () => { setNameInput(e.target.value) },
            () => { setNameInput(nameInput) }
        )
    }

    return (
        <>
            <div>
                <input type="text" placeholder='Name' onChange={handleInputChange} />
                {nameInput}</div>
            <div>
                <button onClick={undoRedo.undo}>Undo</button>
                <button onClick={undoRedo.redo}>Redo</button>
            </div>
        </>
    )
}

When I try to edit the user name, it's giving me the following error: Warning: Cannot update a component ('EditUser') while rendering a different component ('UndoRedoProvider'). To locate the bad setState() call inside 'UndoRedoProvider', follow the stack trace as described in https://reactjs.org/link/setstate-in-render

Can someone explain me what I'm doing wrong?

const { useState, createContext, useContext } = React;

function useUndo() {
    const [state, setState] = useState({ history: [], currentIndex: 0 })

    const execute = (executeFn, undoFn) => {
        const cmd = { executeFn, undoFn } // creates a command object
        setState(prevState => {
            const { history, currentIndex } = prevState // extracts the history from the state object
            cmd.executeFn() // executes the executeFn from command
            return { history: [...history, cmd], currentIndex: currentIndex + 1 } // returns the new state
        })
    }

    const undo = () => {
        setState(prevState => {
            const { history, currentIndex } = prevState // extracts the history from
            if (currentIndex <= 0) return prevState // doesn't undo if there is no history backwards
            const lastCmd = history[currentIndex - 1]
            lastCmd.undoFn() // executes the undoFn from command
            return { history, currentIndex: currentIndex - 1 } // returns the new state
        })
    }

    const redo = () => {
        setState(prevState => {
            const { history, currentIndex } = prevState // extracts the history from
            if (currentIndex >= history.length - 1) return prevState // doesn't redo if there is no history forward
            const nextCmd = history[currentIndex + 1]
            nextCmd.executeFn() // executes the executeFn from command
            return { history, currentIndex: currentIndex + 1 }
        })
    }

    const canUndo = state.currentIndex > 0
    const canRedo = state.currentIndex < history.length - 1

    return { execute, undo, redo, canUndo, canRedo }
}
////// I'm providing this trough a context provider:


const UndoRedoContext = createContext({})

const UndoRedoProvider = (props) => {
    const undoRedo = useUndo();

    return (
        <UndoRedoContext.Provider value={undoRedo}>
            {props.children}
        </UndoRedoContext.Provider>
    );
};

const useUndoRedoContext = () => useContext(UndoRedoContext);


//////Then I have a simple Component for testing purposes:


function EditUser() {
    const undoRedo = useUndoRedoContext()
    const [nameInput, setNameInput] = useState('')
    const handleInputChange = (e) => {
        e.preventDefault()
        const cNameInput = nameInput
        undoRedo.execute(
            () => { setNameInput(e.target.value) },
            () => { setNameInput(nameInput) }
        )
    }

    return (
        <React.Fragment>
            <div>
                <input type="text" placeholder="Name" onChange={handleInputChange} />
                {nameInput}
            </div>
            <div>
                <button onClick={undoRedo.undo}>Undo</button>
                <button onClick={undoRedo.redo}>Redo</button>
            </div>
        </React.Fragment>
    )
}

ReactDOM.createRoot(
    document.getElementById("root")
).render(
    <UndoRedoProvider>
        <EditUser />
    </UndoRedoProvider>
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>
<div id="root">

1

There are 1 best solutions below

2
Elias Schablowski On

The problem that you have is that you are running a state update outside of the component, the best way to fix this is to redesign the undo, redo functionality so that it saves the state.

const { useState, createContext, useContext } = React;

function useUndo() {
    const [history, setHistory] = useState({ history: [], currentIndex: 0 })
    const [state, setState] = useState()

    const execute = (executeFn, undoFn) => {
        const cmd = { executeFn, undoFn } // creates a command object
        setHistory(prevState => {
            const { history, currentIndex } = prevState // extracts the history from the state object
            setState(cmd.executeFn(prevState)) // executes the executeFn from command
            return { history: [...history, cmd], currentIndex: currentIndex + 1 } // returns the new state
        })
    }

    const undo = () => {
        setHistory(prevState => {
            const { history, currentIndex } = prevState // extracts the history from
            if (currentIndex <= 0) return prevState // doesn't undo if there is no history backwards
            const lastCmd = history[currentIndex - 1]
            setState(lastCmd.undoFn()) // executes the undoFn from command
            return { history, currentIndex: currentIndex - 1 } // returns the new state
        })
    }

    const redo = () => {
        setHistory(prevState => {
            const { history, currentIndex } = prevState // extracts the history from
            if (currentIndex >= history.length) return prevState // doesn't redo if there is no history forward
            const nextCmd = history[currentIndex]
            setState(nextCmd.executeFn(prevState)) // executes the executeFn from command
            return { history, currentIndex: currentIndex + 1 }
        })
    }

    const canUndo = history.currentIndex > 0
    const canRedo = history.currentIndex < history.length - 1

    return { execute, undo, redo, canUndo, canRedo, state }
}
////// I'm providing this trough a context provider:


const UndoRedoContext = createContext({})

const UndoRedoProvider = (props) => {
    const undoRedo = useUndo();

    return (
        <UndoRedoContext.Provider value={undoRedo}>
            {props.children}
        </UndoRedoContext.Provider>
    );
};

const useUndoRedoContext = () => useContext(UndoRedoContext);


//////Then I have a simple Component for testing purposes:


function EditUser() {
    const undoRedo = useUndoRedoContext()
    const nameInput = undoRedo.state;
    const handleInputChange = (e) => {
        e.preventDefault()
        const text = e.target.value // Make a copy of the string
        undoRedo.execute(
            () => text,
            () => nameInput
        )
    }

    return (
        <React.Fragment>
            <div>
                <input type="text" placeholder="Name" onChange={handleInputChange} />
                {nameInput}
            </div>
            <div>
                <button onClick={undoRedo.undo}>Undo</button>
                <button onClick={undoRedo.redo}>Redo</button>
            </div>
        </React.Fragment>
    )
}

ReactDOM.createRoot(
    document.getElementById("root")
).render(
    <UndoRedoProvider>
        <EditUser />
    </UndoRedoProvider>
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.js"></script>
<div id="root">