Kent C. Dodds: Here we have a custom hook called useUndo and it's managing three elements of state and two elements of derived state here.
The past is an array of state that happened in the past, the present is the current state, and the future is for the redo scenario where if you undo something, the things that were in the past are now in the future and you get the idea.
Here we have undo and redo, and set and reset, where reset will just reset everything to the initial state whereas set you can add a new element to the present and take the existing present and put it in the past. This all looks just fine.
What we have here is we're just printing out the current state of things but I want to show you a situation where this could actually really cause a problem. Let's say somebody was using this useUndo and they wanted to have a react.useEffect. Based on some sort of side effect, they wanted to call set to second for example. Here we're going to take set and we'll set that to second which is great.
Looks like we're missing set from our dependency list here and that works out just fine because set has been memorized using useCallback. That works just fine. But what if they had a second use effect or maybe we have an asynchronous call and the return values get called out of order or something.
Here we're going to have a second one and this is going to be third. If we say that, oh my goodness. You see it's increasing dramatically. Let's go ahead and stop this before we run into a major problem. Hopefully, we can recover from this. Looks like we're totally busted. What happened here?
What's going on is, as soon as we call set a second, we're going to jump up into this code that's going to call this and we update the past, present, and the future which triggers a rerender. We come down here. Those have been updated. We call this react useCallback and set gets set to a new callback because...oh my goodness.
Looks like we've totally busted this page. Let's exit this and we've got the sad dude here. You don't want that happening in your real app. Let's try and refresh. We're back. OK. Good. Here we have this new callback that we're getting because the past and present were changed.
We get our new set, we stick that into this useEffect callback dependency array and React is like, "Oh, that set function changed so therefore I'm going to call this again and I'm going to call this one again." It just keeps on going forever and ever. It never stops and that's when we get the sad face.
There's actually a way to work around this and that's to make it so we don't have to list these dependencies in our dependency array. Now we could use refs to make that work, but then we'd have some other bugs. Instead what we're going to do is, I'm going to combine all of the states into a single useState and then use a state updater function.
We'll say const state, setState equals react useState. We'll have past is an empty array, present is the initial present, and future is an empty array. Great. We can get rid of all of those. We'll have this come off the state. Then for each one of these, we have to get rid of everything in this dependency array so we can just empty it entirely.
What I'm going to do is we'll call setState. We'll get the current state and we'll put all of this stuff inside of that function and base everything off of the current state. Let's go ahead and we'll just pluck off past, present, and future from the currentState. We can't use canUndo, otherwise we'd have to include canUndo there.
I'm just going to grab this and we'll change this around. That'll be if the length is zero, then we're going to return the currentState. By returning the currentState, that's going to mean we don't get a re-render which is the desired outcome here anyway. Then instead of calling all these individual setters, we're just going to say, transfer those to lowercase.
These are going to be properties in an object that I'm going to return. Perfect. That's a straight-up refactor right there. Let's go ahead and refactor this one so we can get rid of that dependency list. We'll call setState. We'll get our currentState.
We'll put all these stuff in here and canRedo is going to be similar to canUndo which is calculated from the future length. Let's go ahead and say past, present, and future come from the currentState. If we have a future.length that is zero, then we'll return the currentState, no re-render necessary.
Then for all of these, we can return these as an object. We'll get rid of this set here. We'll lowercase those and set those to those values. Great. Then for set, we're going to accept that new preset and we're going to call setState. With the currentState we'll get our past, present, and future from the currentState. Let's bring all of this stuff up.
We'll get rid of the elements in that dependency array. It looks like we don't need the future. Let's get rid of the future. In this case, we'll return the currentState and then we'll say, "Return an object of all of these as properties of our new state."
Finally, this one is quite a bit simpler. We can simply call setState and we'll have all of these as properties of that setState call. We'll return the state object. Cool. Everything is working fine. Now, with a little bit of trepidation, I'm going to uncomment that and then uncomment that. Sweet glory! That's exactly what I wanted.
I don't want the set function to change as I'm changing the state. That's exactly what's happening. We were able to accomplish this by putting all of our state into a single-use state call. That works OK but we have to put a whole bunch of updating logic in here. It makes a lot more sense to use a reducer. Let's go ahead and use a reducer.
I'm going to make a function called undoReducer. That's going to take a state and a action. Here I'm going to get the past, present, and future from my state and my actions are going to take a type and a new present. We'll have a switch on the type, and we'll have a case for undo. All of the stuff we have in our undo function can come right up to our case.
I'm going to copy everything between that opening curly brace and we'll paste it right here. Instead of currentState and plucking all those off, we can get rid of those. Instead of returning the currentState, we'll return the state. To get rid of all these yellow squiggle, I'm going to add a default that throws a new error unhandled action type. Good. That's our undo action.
Let's add a case for redo. Our redo is going to be very similar. We'll grab everything between these curly braces, the stuff we were doing in that updater function, and we'll move it right there. We can get rid of the plucking and get rid of the state. We're doing the plucking up here. We're going to return the state to avoid an unnecessary re-render if there is no future.
Let's put that in a string so that syntax is correct. Let's make another case for set. I'm going to come down here, grab my set function right there. Copy all that. Come up here and paste it in place here. Get rid of that and return the state. Perfect. Our last one for reset, we'll come down here and grab that. In this case, we're going to return that. Perfect.
Now, I can say, const state and dispatch equals React useReducer. That's going to be our undo reducer. Here's our initial state. I'll grab that from there. We'll get rid of these. Instead of putting all of the logic here, I'm going to call dispatch type undo. We'll do the same thing for this one, dispatch type redo. For set, we're going to need to pass on the new present.
We'll dispatch a type set and a newPresent before that along there. We'll come to reset, and we'll set this to reset. Cool. Now, everything's working exactly as it was before. I would argue that things are quite a bit cleaner because we've been able to put all of our logic in this reducer and our hook is not cluttered with all that logic.
Our hook is just responsible for creating little helper functions that forward on to dispatch. We don't have to worry about memorization because we don't have any dependencies in the array of our call back and certainly no dependencies that could change on us unexpectedly.
While we can implement all of this stuff with useState, I would argue that, in this scenario, it makes a lot of sense to use a reducer instead. The general rule for this is that when one element of your state relies on the value of another element of your state to update, then you should use reducer.
An example of that here is our past relies on the present to update itself. Therefore, they should be in a reducer together because they change together.