DEV Community

OlumideSamuel
OlumideSamuel

Posted on

Undo and Redo Counter - A must know React Javascript Frontend Interview Question (Part 4)

Undo and Redo Counter

This project is a simple counter that can increase or decrease by some set values, keep track of add and minus math operations performed and allow users "undo" and "redo" the operations.

Requirements

  • The count should begin at 0
  • Clicking the +1, +10, +100 buttons should add 1, 10, and 100 to the count, respectively
  • Clicking the -1, -10, -100 buttons should subtract 1, 10, and 100 from the count, respectively
  • Clicking any + or - button should show a new entry in the history, in the format: ACTION (BEFORE -> AFTER) (e.g. +1 (0 -> 1))
  • Clicking the 'Undo' button should undo the last action. For example, if the user just clicked '+10', clicking undo should subtract 10 from the count
  • The user should be able to undo as many operations as possible
  • The 'Redo' button should be greyed out until the user clicks 'Undo'
  • Clicking the 'Redo' button should redo the last action the user undid. For example, if the user clicked '+10', clicking undo would subtract 10, then clicking redo would add 10 again
  • Clicking undo/redo should remove and re-add entries to the history respectively

Sketch of the Problem

Problem from FrontendEval1

Link to the Vanilla-JS solution will be added below.2

Solution Walkthrough

Defining the base states

  • The project requires to add or subtract a value from 0 (result) as the user clicks on the defined buttons.
  • Then, list each of this operation in the format ACTION_VALUE (PREVIOUS_RESULT_VALUE -> CURRENT_RESULT_VALUE).

Note:

  • actionVal: represents the value of the clicked button. represents by what amount to increase or decrease the result
  • prevVal: represents the previous result before the new math operation is done
  • afterVal: represents the current result of the math operation
    • When the Undo button is clicked, reverse the last operation
    • When the Redo button is clicked, undo the last redo action

Hence, define the local states in our App.js as:

...

export type HistoryType = {
  actionVal: number; 
  prevVal: number; 
  afterVal: number; 
};

function App() {
  const [historyList, setHistoryList] = useState<HistoryType[]>([]);
  const [result, setResult] = useState(0);
  const [redoHistoryList, setRedoHistoryList] = useState<HistoryType[]>([]);

...
}
Enter fullscreen mode Exit fullscreen mode

Defining the base components

Doable Controls
This component controls the Undo and Redo click actions on the buttons.

...
type DoableControlsProp = {
  handleUndo: () => void;
  handleRedo: () => void;
  historyList: HistoryType[];
  redoHistoryList: HistoryType[];
};

export const DoableControls: FC<DoableControlsProp> = ({
  handleUndo,
  handleRedo,
  historyList,
  redoHistoryList,
}) => {
  return (
    <div>
      <button
        className="controlbtn"
        id={"undoBtn"}
        onClick={handleUndo}
        disabled={historyList.length === 0}
      >
        Undo
      </button>
      <button
        className="controlbtn"
        id="redoBtn"
        onClick={handleRedo}
        disabled={redoHistoryList.length === 0}
      >
        Redo
      </button>
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode
Main Controls

The results, increment and decrement buttons are defined in the component. Since the defined actions are finite, let's set each button to have an id that defines the action it triggers for ease of use. E.g plus1, minus1, plus10 etc.

When a user clicks a button, pass the value defined on the button to the parent container, and update the list of objects in the history with the current result (prevVal), the just clicked button value (btnValue), and the new result after operation (afterVal) is done.

const MainControls: FC<MainControlsProps> = ({
  handleHistoryUpdate,
  result,
  setResult,
}) => {
  const handleOnBtnPress = (event: React.MouseEvent<HTMLButtonElement>) => {
    const target = event.target as HTMLButtonElement;
    const btnValue = parseInt(target.innerText);
    const newResult = result + btnValue;

    setResult(btnValue);
    handleHistoryUpdate({
      actionVal: btnValue,
      prevVal: result,
      afterVal: newResult,
    });
  };
  return (
    <div>
      <button className="controlbtn" id="minus100" onClick={handleOnBtnPress}>
        -100
      </button>
      <button className="controlbtn" id="minus10" onClick={handleOnBtnPress}>
        -10
      </button>
      <button className="controlbtn" id="minus1" onClick={handleOnBtnPress}>
        -1
      </button>

      <span id="result">{result}</span>

      <button className="controlbtn" id="plus1" onClick={handleOnBtnPress}>
        1
      </button>
      <button className="controlbtn" id="plus10" onClick={handleOnBtnPress}>
        10
      </button>
      <button className="controlbtn" id="plus100" onClick={handleOnBtnPress}>
        100
      </button>
    </div>
  );
};

export default MainControls;

Enter fullscreen mode Exit fullscreen mode
Rendering History of Past Operations

To render the history of the past operations, map through the historyList state and format the data as
ACTION_VALUE (PREVIOUS_RESULT_VALUE -> CURRENT_RESULT_VALUE).

// in App.js
...
 <div>
  <h3>History Info</h3>
  {historyList.map((item, index) => {
    return (
      <div key={index}>
        {item.actionVal} ({item.prevVal} {`->`} {item.afterVal})
      </div>
    );
  })}
</div>
...
Enter fullscreen mode Exit fullscreen mode
On History Update

When a user clicks any of the MainControls buttons to add or substract from the result, define a function to update the history and results.

Updating result will always work with the plus operation because from elementary maths,
2 + 1 = 3 and 2 + -1 => 2 - 1 = 1

Also, because we want the most recent operation to be added to the top of the history list, add the item before appending the rest behind it. That is, always at index 0.

...
const onUpdateHistory = (payload: HistoryType) => {
  // adding the most recent to top of list
  setHistoryList((prev) => [payload, ...prev]);
};

const handleUpdateResult = (btnValue: number) => {
  setResult((prev: number) => prev + btnValue);
};
...

Enter fullscreen mode Exit fullscreen mode

Handling Undo Operation

Remember this format from above
ACTION_VALUE (PREVIOUS_RESULT_VALUE -> CURRENT_RESULT_VALUE)
where

  • ACTION_VALUE = value on button clicked
  • PREVIOUS_RESULT_VALUE = the default result before the math operation
  • CURRENT_RESULT_VALUE = the new result after the math operation

Then onClick the undoBtn, abort this operation if historyList is empty.

In the vanilla-js solution to this project, new items are added to the bottom of the list. However, in this solution, new items are added to the top of the list.

Hence, updating the list requires getting the just added element in index 0 using the javascript shift inorder to recalculate the previous state.

To get the previous result (newAfterVal), substract the previous actionVal (the value on previously clicked button) from the old result (afterVal).
This implies => newAfterVal = afterVal - actionVal;

Then update the result, historyList and redoHistoryList with the new data.

...
  const handleUndo = () => {
    if (historyList.length === 0) {
      return;
    }

    const tempList = [...historyList];
    const shifted = tempList.shift();
    if (!shifted) {
      return;
    }
    const { actionVal, afterVal, prevVal } = shifted;

    let newAfterVal = result;
    newAfterVal = afterVal - actionVal;

    setResult(newAfterVal);
    setHistoryList(tempList);
    setRedoHistoryList((prev) => [
      ...prev,
      { afterVal: newAfterVal, prevVal, actionVal },
    ]);
  };
  ...
Enter fullscreen mode Exit fullscreen mode

Handling Redo Operation

The redo operation gets the most recent undo object added to the top of the redoHistoryList by the undo action, and calculates the new result.

...
  const handleRedo = () => {
    if (redoHistoryList.length === 0) {
      return;
    }

    const popped = redoHistoryList.pop();
    if (!popped) {
      return;
    }
    const { actionVal, prevVal } = popped;
    const newRes = result + actionVal;
    setResult((prev) => prev + actionVal);
    onUpdateHistory({ actionVal, prevVal, afterVal: newRes });
  };
...
Enter fullscreen mode Exit fullscreen mode

The End Result

the final result

Link to the Github repository


  1. https://frontendeval.com/questions/undoable-counter 

  2. Walkthrough of the vanilla javascript solution 

Top comments (0)