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