DEV Community

Cover image for Enhance Your React App With Undo and Reset Abilities
jsmanifest
jsmanifest

Posted on • Updated on • Originally published at jsmanifest.com

Enhance Your React App With Undo and Reset Abilities

Find me on medium

Ever developed something where you made mistakes and wished there was an undo feature? What about reset?

Luckily there is always undo or reset capabilities in software we use. I'm talking ctrl + z in vscode, or commonly in the 90s a reset button in forms.

Why do we even need them? Well, because humans always make mistakes. Whether it is a typo or wrong wording in a written essay, we need some way to undo something. When you think about it though, there are ways to undo something almost everywhere. Pencils have erasers, phones have capabilities to be taken apart, users are given the option to reset their passwords, erasable pens rubbing their ink away--the list goes on.

But what about implementing an undo or reset feature as a developer for an application? Where do you start? Where should you look for advice?

Well, look no further because I am here to show you how to enhance your application with undo and reset capabilities! You will find out from this article that it isn't that hard to implement and you can do it too.

What we'll be building is a user interface where users are able to add their friends by name and specifying their friend's gender. As friends are inserted, cards will be appended to the screen displaying the information the friend was registered with. In addition, if their friend is a female it will be displayed with a hotpink colored borderline, while the males will have a teal borderline. If the user made a mistake when registering the friend, he or she may choose to undo that action or reset the entire interface back to its beginning state. And finally, they will be able to change their interface theme color in case they like dark over light, or vice versa.

Here is what that will look like:

Light

final light

Dark

final dark

Without further ado, let's begin!

In this tutorial we are going to quickly generate a react project with create-react-app.

(If you want to get a copy of the repository from github, click here).

Go ahead and create a project using the command below. For this tutorial i’ll call our project undo-reset.

npx create-react-app undo-reset
Enter fullscreen mode Exit fullscreen mode

Now go into the directory once it's done:

cd undo-reset
Enter fullscreen mode Exit fullscreen mode

Inside the main entry src/index.js we're going to clean it up a bit:

src/index.js

import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
import './styles.css'
import * as serviceWorker from './serviceWorker'

ReactDOM.render(<App />, document.getElementById('root'))
serviceWorker.unregister()
Enter fullscreen mode Exit fullscreen mode

Here are the starting styles:

src/styles.css

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
    'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
    'Helvetica Neue', sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}
Enter fullscreen mode Exit fullscreen mode

Now create src/App.js. This will render all the components we'll be building throughout the tutorial:

src/App.js

import React, { useState } from 'react'

const App = () => {
  const [name, setName] = useState('')
  const [gender, setGender] = useState('Male')
  const onNameChange = (e) => setName(e.target.value)
  const onGenderChange = (e) => setGender(e.target.value)

  return <div />
}

export default App
Enter fullscreen mode Exit fullscreen mode

Since we'll be letting the user add their friends and specifying the names and genders, we defined a couple of react hooks to hold the input values and we'll also define the methods to update them.

We'll then implement the elements and input fields that the hooks will attach themselves to:

src/App.js

const App = () => {
  const [name, setName] = useState('')
  const [gender, setGender] = useState('Male')
  const onNameChange = (e) => setName(e.target.value)
  const onGenderChange = (e) => setGender(e.target.value)

  return (
    <div>
      <form className="form">
        <div>
          <input
            onChange={onNameChange}
            value={name}
            type="text"
            name="name"
            placeholder="Friend's Name"
          />
        </div>
        <div>
          <select onChange={onGenderChange} name="gender" value={gender}>
            <option value="Male">Male</option>
            <option value="Female">Female</option>
            <option value="Other">Other</option>
          </select>
        </div>
        <div>
          <button type="submit">Add</button>
        </div>
      </form>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

src/styles.css

form {
  display: flex;
  align-items: center;
}

form > div {
  margin: auto 3px;
}

input,
select {
  transition: all 0.15s ease-out;
  border: 1px solid #ddd;
  padding: 10px 14px;
  outline: none;
  font-size: 14px;
  color: #666;
}

input:hover,
select:hover {
  border: 1px solid #c6279f;
}

select {
  cursor: pointer;
  padding-top: 9px;
  padding-bottom: 9px;
}

button {
  transition: all 0.15s ease-out;
  background: #145269;
  border: 1px solid #ddd;
  padding: 10px 35px;
  outline: none;
  cursor: pointer;
  color: #fff;
}

button:hover {
  color: #145269;
  background: #fff;
  border: 1px solid #145269;
}

button:active {
  background: rgb(27, 71, 110);
  border: 1px solid #a1a1a1;
  color: #fff;
}
Enter fullscreen mode Exit fullscreen mode

Now I don't like to keep my interface super plain in my tutorials--after all, I do value your time put into reading my posts, so I provided some thought into the styles to keep you away from boredom :)

Next, we need a solid place to put the undo and reset logic in, so we'll create a custom hook that will handle state updates:

src/useApp.js

const useApp = () => {
  const onSubmit = (e) => {
    e.preventDefault()
    console.log('Submitted')
  }

  return {
    onSubmit,
  }
}

export default useApp
Enter fullscreen mode Exit fullscreen mode

The onSubmit above is going to be passed into the form we defined earlier, which will help append friends to the friends list when the user submits them:

src/App.js

import React, { useState } from 'react'
import useApp from './useApp'

const App = () => {
  const { onSubmit } = useApp()

  const [name, setName] = useState('')
  const [gender, setGender] = useState('Male')
  const onNameChange = (e) => setName(e.target.value)
  const onGenderChange = (e) => setGender(e.target.value)

  return (
    <div>
      <form className="form" onSubmit={onSubmit({ name, gender })}>
        <div>
          <input
            onChange={onNameChange}
            value={name}
            type="text"
            name="name"
            placeholder="Friend's Name"
          />
        </div>
        <div>
          <select onChange={onGenderChange} name="gender" value={gender}>
            <option value="Male">Male</option>
            <option value="Female">Female</option>
            <option value="Other">Other</option>
          </select>
        </div>
        <div>
          <button type="submit">Add</button>
        </div>
      </form>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Something to note here is that onSubmit is given the field parameters as arguments. If we look back at our onSubmit handler it isn't a higher order function. That means it will become invoked immediately as the component mounts, so we need to convert the onSubmit handler to be a higher order function to bypass that as well as giving it the ability to receive the values of the fields:

src/useApp.js

const useApp = () => {
  const onSubmit = (friend) => (e) => {
    e.preventDefault()
    console.log(friend)
  }

  return {
    onSubmit,
  }
}

export default useApp
Enter fullscreen mode Exit fullscreen mode

So far, we have this:

Next we'll start implementing the logic. But first, we need to define the state structure:

src/useApp.js

const initialState = {
  friends: [],
  history: [],
}
Enter fullscreen mode Exit fullscreen mode

The most important part of this tutorial is history. When the user submits an action, we're going to capture the state of the app and safely store it in a place where we can reference later to undo user actions. This "storage" is state.history which only our custom hook needs to know about. However, it can also be used in the user interface for interesting functionality--like allowing the user to view their previous actions through a grid and choosing which one to go back to. That is a handy little feature to wow your users!

Next, we're going to add the switch cases in the reducer so that our state can actually update:

src/useApp.js

const reducer = (state, action) => {
  switch (action.type) {
    case 'add-friend':
      return {
        ...state,
        friends: [...state.friends, action.friend],
        history: [...state.history, state],
      }
    case 'undo': {
      const isEmpty = !state.history.length
      if (isEmpty) return state
      return { ...state.history[state.history.length - 1] }
    }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

When we dispatch an action with type 'add-friend', we went ahead and added the new friend to the list. But what the user doesn't know is that we are silently saving their previous edits. We captured the most recent state of the app and saved it in the history array. This way, if the user ever wants to come back to a previous state we can help them make that happen :)

Since we're using a react hook api, we musn't forget to import it from react. We also need to define the useReducer implementation inside our custom hook so that we acquire the api to send signals to update our local state:

src/useApp.js

import { useReducer } from 'react'

// ... further down inside the custom hook:
const [state, dispatch] = useReducer(reducer, initialState)
Enter fullscreen mode Exit fullscreen mode

Now that we acquired the apis, let's incorporate them to places that need it:

src/useApp.js

const onSubmit = (friend) => (e) => {
  e.preventDefault()
  if (!friend.name) return
  dispatch({ type: 'add-friend', friend })
}

const undo = () => {
  dispatch({ type: 'undo' })
}
Enter fullscreen mode Exit fullscreen mode

Here's what our custom hook looks like so far:

src/useApp.js

import { useReducer } from 'react'

const initialState = {
  friends: [],
  history: [],
}

const reducer = (state, action) => {
  switch (action.type) {
    case 'add-friend':
      return {
        ...state,
        friends: [...state.friends, action.friend],
        history: [...state.history, state],
      }
    case 'undo': {
      const isEmpty = !state.history.length
      if (isEmpty) return state
      return { ...state.history[state.history.length - 1] }
    }
    default:
      return state
  }
}

const useApp = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  const onSubmit = (friend) => (e) => {
    e.preventDefault()
    if (!friend.name) return
    dispatch({ type: 'add-friend', friend })
  }

  const undo = () => {
    dispatch({ type: 'undo' })
  }

  return {
    ...state,
    onSubmit,
    undo,
  }
}

export default useApp
Enter fullscreen mode Exit fullscreen mode

Next, we're going to need to render the list of friends that are inserted into state.friends so that the user can see them in the interface:

src/App.js

const App = () => {
  const { onSubmit, friends } = useApp()

  const [name, setName] = useState('')
  const [gender, setGender] = useState('Male')
  const onNameChange = (e) => setName(e.target.value)
  const onGenderChange = (e) => setGender(e.target.value)

  return (
    <div>
      <form className="form" onSubmit={onSubmit({ name, gender })}>
        <div>
          <input
            onChange={onNameChange}
            value={name}
            type="text"
            name="name"
            placeholder="Friend's Name"
          />
        </div>
        <div>
          <select onChange={onGenderChange} name="gender" value={gender}>
            <option value="Male">Male</option>
            <option value="Female">Female</option>
            <option value="Other">Other</option>
          </select>
        </div>
        <div>
          <button type="submit">Add</button>
        </div>
      </form>
      <div className="boxes">
        {friends.map(({ name, gender }, index) => (
          <FriendBox key={`friend_${index}`} gender={gender}>
            <div className="box-name">Name: {name}</div>
            <div className="gender-container">
              <img src={gender === 'Female' ? female : male} alt="" />
            </div>
          </FriendBox>
        ))}
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

If you're wondering what this odd line is doing:

<img src={gender === 'Female' ? female : male} alt="" />
Enter fullscreen mode Exit fullscreen mode

I actually just provided my own images to render on the img element in order to easily differentiate between a female and male in the interface--for demonstration purposes. Those of you who are cloning the repository will be able to see them in the src/images directory if you need a copy of them :)

We import the female/male images at the top of App.js, and right above the App component we'll define a FriendBox component which will be responsible for rendering a friend box as the user adds to them to the list:

src/App.js

// At the top
import female from './images/female.jpg'
import male from './images/male.jpg'

// Somewhere above the App component
const FriendBox = ({ gender, ...props }) => (
  <div
    className={cx('box', {
      'teal-border': gender === 'Male',
      'hotpink-border': gender === 'Female',
    })}
    {...props}
  />
)
Enter fullscreen mode Exit fullscreen mode

In order to further differentiate between a female and male in a visual perspective, I additionally added in basic styles to represent each:

src/styles.css

.teal-border {
  border: 1px solid #467b8f;
}

.hotpink-border {
  border: 1px solid #c1247d;
}
Enter fullscreen mode Exit fullscreen mode

And here is what we have so far for the App.js file:

src/App.js

import React, { useState } from 'react'
import cx from 'classnames'
import female from './images/female.jpg'
import male from './images/male.jpg'
import useApp from './useApp'

const FriendBox = ({ gender, ...props }) => (
  <div
    className={cx('box', {
      'teal-border': gender === 'Male',
      'hotpink-border': gender === 'Female',
    })}
    {...props}
  />
)

const App = () => {
  const { onSubmit, friends } = useApp()

  const [name, setName] = useState('')
  const [gender, setGender] = useState('Male')
  const onNameChange = (e) => setName(e.target.value)
  const onGenderChange = (e) => setGender(e.target.value)

  return (
    <div>
      <form className="form" onSubmit={onSubmit({ name, gender })}>
        <div>
          <input
            onChange={onNameChange}
            value={name}
            type="text"
            name="name"
            placeholder="Friend's Name"
          />
        </div>
        <div>
          <select onChange={onGenderChange} name="gender" value={gender}>
            <option value="Male">Male</option>
            <option value="Female">Female</option>
            <option value="Other">Other</option>
          </select>
        </div>
        <div>
          <button type="submit">Add</button>
        </div>
      </form>
      <div className="boxes">
        {friends.map(({ name, gender }, index) => (
          <FriendBox key={`friend_${index}`} gender={gender}>
            <div className="box-name">Name: {name}</div>
            <div className="gender-container">
              <img src={gender === 'Female' ? female : male} alt="" />
            </div>
          </FriendBox>
        ))}
      </div>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

The styles used for the boxes here are:

src/styles.css

.boxes {
  margin: 10px 0;
  padding: 3px;
  display: grid;
  grid-gap: 10px;
  grid-template-columns: repeat(3, 1fr);
  grid-template-rows: 1fr;
}

.box {
  font-size: 18px;
  display: flex;
  justify-content: center;
  align-items: center;
  flex-direction: column;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.box-name {
  display: flex;
  align-items: center;
  height: 50px;
}

.box.gender-container {
  position: relative;
}

.box img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}
Enter fullscreen mode Exit fullscreen mode

Oh, bummer! One thing we forgot to do is bring in the undo method so we can use it in the interface! Go ahead and destructure that out from useApp and place it on the Undo button:

src/App.js

const App = () => {
  const { onSubmit, friends, undo } = useApp()

  const [name, setName] = useState('')
  const [gender, setGender] = useState('Male')
  const onNameChange = (e) => setName(e.target.value)
  const onGenderChange = (e) => setGender(e.target.value)

  const resetValues = () => {
    setName('')
    setGender('Male')
  }

  return (
    <div>
      <form className="form" onSubmit={onSubmit({ name, gender }, resetValues)}>
        <div>
          <input
            onChange={onNameChange}
            value={name}
            type="text"
            name="name"
            placeholder="Friend's Name"
          />
        </div>
        <div>
          <select onChange={onGenderChange} name="gender" value={gender}>
            <option value="Male">Male</option>
            <option value="Female">Female</option>
            <option value="Other">Other</option>
          </select>
        </div>
        <div>
          <button type="submit">Add</button>
        </div>
      </form>
      <div className="undo-actions">
        <div>
          <button type="button" onClick={undo}>
            Undo
          </button>
        </div>
      </div>
      <div className="boxes">
        {friends.map(({ name, gender }, index) => (
          <FriendBox key={`friend_${index}`} gender={gender}>
            <div className="box-name">Name: {name}</div>
            <div className="gender-container">
              <img src={gender === 'Female' ? female : male} alt="" />
            </div>
          </FriendBox>
        ))}
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now when the user hits the Undo button, their last action should be restored!

2

Everything's going perfectly as planned. The user is able to add their friends to the list, easily notice which ones are female and male in the interface, and undo their previous submissions.

...did you also notice that there's now a resetValues method in the App component, where it is being passed in to onSubmit as the second argument? One thing that might feel a little bit odd for users is that their input isn't clearing after they submit a friend. Do they still need that same name there? Unless they have two or three friends with the same name, they're sure to hit their backspace button and clear it themselves. But we as developers have the ability to make their lives easier, so that's why we implemented a resetValues.

Having said that, it should be declared as the second parameter to onSubmit since we passed it in as the second argument in the UI component:

src/useApp.js

const useApp = () => {
  const [state, dispatch] = useReducer(reducer, initialState)

  const onSubmit = (friend, resetValues) => (e) => {
    e.preventDefault()
    if (!friend.name) return
    dispatch({ type: 'add-friend', friend })
    resetValues()
  }

  const undo = () => {
    dispatch({ type: 'undo' })
  }

  return {
    ...state,
    onSubmit,
    undo,
  }
}
Enter fullscreen mode Exit fullscreen mode

Our undo feature should be working 100% fine by now, but i'm going to go a little further to make this a little more complex because an undo can be compatible with just about anything.

Therefore, we're going to allow the user to declare a theme color for the interface so that they won't get too bored of white:

src/useApp.js

const initialState = {
  friends: [],
  history: [],
  theme: 'light',
}
Enter fullscreen mode Exit fullscreen mode

src/useApp.js

const reducer = (state, action) => {
  switch (action.type) {
    case 'set-theme':
      return { ...state, theme: action.theme, history: insertToHistory(state) }
    case 'add-friend':
      return {
        ...state,
        friends: [...state.friends, action.friend],
        history: insertToHistory(state),
      }
    case 'undo': {
      const isEmpty = !state.history.length
      if (isEmpty) return state
      return { ...state.history[state.history.length - 1] }
    }
    case 'reset':
      return { ...initialState, history: insertToHistory(state) }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

Also, I declared an insertToHistory utility to bring us extra benefits in case we passed in a weird value in the future for the state argument as you might have noticed above:

const insertToHistory = (state) => {
  if (state && Array.isArray(state.history)) {
    // Do not mutate
    const newHistory = [...state.history]
    newHistory.push(state)
    return newHistory
  }
  console.warn(
    'WARNING! The state was attempting capture but something went wrong. Please check if the state is controlled correctly.',
  )
  return state.history || []
}
Enter fullscreen mode Exit fullscreen mode

I'd like to add that it's a very important habit to think ahead as your app gets larger and more complex.

Now continuing with the theme implementation, we'll define a custom method that the UI components can leverage:

src/useApp.js

const onThemeChange = (e) => {
  dispatch({ type: 'set-theme', theme: e.target.value })
}

return {
  ...state,
  onSubmit,
  undo,
  onThemeChange,
}
Enter fullscreen mode Exit fullscreen mode

Applying the theme components and the method to the interface:

src/App.js

import React, { useState } from 'react'
import cx from 'classnames'
import female from './images/female.jpg'
import male from './images/male.jpg'
import useApp from './useApp'

const FriendBox = ({ gender, ...props }) => (
  <div
    className={cx('box', {
      'teal-border': gender === 'Male',
      'hotpink-border': gender === 'Female',
    })}
    {...props}
  />
)

const App = () => {
  const { onSubmit, friends, undo, theme, onThemeChange } = useApp()

  const [name, setName] = useState('')
  const [gender, setGender] = useState('Male')
  const onNameChange = (e) => setName(e.target.value)
  const onGenderChange = (e) => setGender(e.target.value)

  const resetValues = () => {
    setName('')
    setGender('Male')
  }

  return (
    <div>
      <div>
        <h3>What theme would you like to display?</h3>
        <div>
          <select onChange={onThemeChange} name="theme" value={theme}>
            <option value="light">Light</option>
            <option value="dark">Dark</option>
          </select>
        </div>
      </div>
      <div>
        <h3>Add a friend</h3>
        <form
          className="form"
          onSubmit={onSubmit({ name, gender }, resetValues)}
        >
          <div>
            <input
              onChange={onNameChange}
              value={name}
              type="text"
              name="name"
              placeholder="Friend's Name"
            />
          </div>
          <div>
            <select onChange={onGenderChange} name="gender" value={gender}>
              <option value="Male">Male</option>
              <option value="Female">Female</option>
              <option value="Other">Other</option>
            </select>
          </div>
          <div>
            <button type="submit">Add</button>
          </div>
        </form>
      </div>
      <div>
        <h3>Made a mistake?</h3>
        <div className="undo-actions">
          <div>
            <button type="button" onClick={undo}>
              Undo
            </button>
          </div>
        </div>
      </div>
      <div className="boxes">
        {friends.map(({ name, gender }, index) => (
          <FriendBox key={`friend_${index}`} gender={gender}>
            <div className="box-name">Name: {name}</div>
            <div className="gender-container">
              <img src={gender === 'Female' ? female : male} alt="" />
            </div>
          </FriendBox>
        ))}
      </div>
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

Since we added the theme changing feature, it's probably a good idea to add in some conditional styles as well to accomodate the changes, right?

 <div className={cx({
    'theme-light': theme === 'light',
    'theme-dark': theme === 'dark',
  })}
  // ...rest of the component
Enter fullscreen mode Exit fullscreen mode

And here are the styles for that:

src/styles.css

.theme-light,
.theme-dark {
  box-sizing: border-box;
  transition: all 0.15s ease-out;
  padding: 12px;
  min-height: 100vh;
}

.theme-light {
  color: #145269;
  background: #fff;
}

.theme-dark {
  color: #fff;
  background: #0b2935;
}
Enter fullscreen mode Exit fullscreen mode

Awesome! Here is what our interface can do now!

3

Give yourselves a round of applause for making it this far!

Let's not celebrate yet though, because the title of this article also mentions a reset feature for the interface.

Let's do that now by defining the switch case right on the reducer that we currently have:

src/useApp.js

const reducer = (state, action) => {
  switch (action.type) {
    case 'set-theme':
      return { ...state, theme: action.theme, history: insertToHistory(state) }
    case 'add-friend':
      return {
        ...state,
        friends: [...state.friends, action.friend],
        history: insertToHistory(state),
      }
    case 'undo': {
      const isEmpty = !state.history.length
      if (isEmpty) return state
      return { ...state.history[state.history.length - 1] }
    }
    case 'reset':
      return { ...initialState, history: insertToHistory(state) }
    default:
      return state
  }
}
Enter fullscreen mode Exit fullscreen mode

Now of course what that means next is having to define the method to signal that reducer for the state change. Don't forget to return it at the end of the hook!

src/useApp.js

const reset = () => {
  dispatch({ type: 'reset' })
}

const onThemeChange = (e) => {
  dispatch({ type: 'set-theme', theme: e.target.value })
}

return {
  ...state,
  onSubmit,
  onThemeChange,
  undo,
  reset,
}
Enter fullscreen mode Exit fullscreen mode

Destructuring it from the hook in the UI component:

src/App.js

const { onSubmit, friends, undo, theme, onThemeChange, reset } = useApp()
Enter fullscreen mode Exit fullscreen mode

src/App.js

<div>
  <h3>Made a mistake?</h3>
  <div className="undo-actions">
    <div>
      <button type="button" onClick={undo}>
        Undo
      </button>
    </div>
    <div>
      <button type="button" onClick={reset}>
        Reset
      </button>
    </div>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Last but not least, the styles used for those actions to align them horizontally:

src/styles.css

.undo-actions {
  display: flex;
  align-items: center;
}

.undo-actions > div {
  margin: auto 3px;
}
Enter fullscreen mode Exit fullscreen mode

Result:

4

Don't you just love how resetting the interface is also being captured by undo?

If you chose to download and clone the repository, you will see slight modifications as shown below:

src/App.js

import React, { useState } from 'react'
import cx from 'classnames'
import useApp from './useApp'
import ThemeControl from './ThemeControl'
import AddFriend from './AddFriend'
import UndoResetControl from './UndoResetControl'
import Friends from './Friends'
import './styles.css'

const App = () => {
  const { friends, theme, onSubmit, onThemeChange, undo, reset } = useApp()

  const [name, setName] = useState('')
  const [gender, setGender] = useState('Male')
  const onNameChange = (e) => setName(e.target.value)
  const onGenderChange = (e) => setGender(e.target.value)

  const resetValues = () => {
    setName('')
    setGender('Male')
  }

  return (
    <div
      className={cx({
        'theme-light': theme === 'light',
        'theme-dark': theme === 'dark',
      })}
    >
      <ThemeControl theme={theme} onChange={onThemeChange} />
      <AddFriend
        onSubmit={onSubmit({ name, gender }, resetValues)}
        onNameChange={onNameChange}
        onGenderChange={onGenderChange}
        currentValues={{ name, gender }}
      />
      <UndoResetControl undo={undo} reset={reset} />
      <Friends friends={friends} />
    </div>
  )
}

export default App
Enter fullscreen mode Exit fullscreen mode

It is the same code except I organized it to be a little more readable and maintainable by separating the components to their own files.

Bonus

Earlier in the beginning of the tutorial I mentioned an interface that you can display to users--giving them the option to choose which previous state of the app that they can revert back to if desired. Here is an example of that in use:

bonus

Conclusion

Undoing things is very useful to us because we humans never stop making mistakes... let's face the truth. I hope that means you found this to be really useful to you :)

See you guys next time and you can follow me if you want to read more from me in the future!

Feel free to follow me on medium!

Top comments (7)

Collapse
 
paveltrufi profile image
Pavel Razgovorov

Amazing tutorial, congratulations!

However, I'm concerned about the fact that you're storing in the history the hole state every time you change it. I think that storing only the difference between state changes would be way more efficient. How would you implement that approach?

Collapse
 
rohanbagchi profile image
Rohan Bagchi • Edited

Actually immer solves it elegantly. Storing only diffs as user intents. Very valuable when building a system being worked on by many clients at the same time.

Lookup medium.com/@mweststrate/distributi...

Collapse
 
paveltrufi profile image
Pavel Razgovorov • Edited

I didn't hear about this library. I'll try to check it out when I'll have time!

Collapse
 
jsmanifest profile image
jsmanifest • Edited

Hi Pavel and thank you for the kind compliment!

That is a good question! To implement an undo/reset feature for specific parts of the state, you can actually just use the same approach but instead of passing in the entire state, you only need to pass in a specific part of the state as you choose. That part of the state is the what you'd pass to the user interface and any child component can inherit off of that. You can additionally declare a second, third, fourth state.history under a different alias and use that for other slices of the state.

If you'd like me to, I can write on an upcoming tutorial on how to create an application implementing multiple undos for three different components all in the same interface, completely isolated away from eachother and still sharing the same root state)

Collapse
 
paveltrufi profile image
Pavel Razgovorov

That would be great if you want to do it. Anyway I would try to implement some sort of Command pattern with the ability to "redo" the command as well (something I missed in this tutorial) 🤔, so the friends array are constructed based on the actions performed, and not maintaining two separate arrays.

Collapse
 
ccunnin297 profile image
Cole Cunningham

Awesome tutorial! Great to see how this works with React hooks as well. Keep up the good work!

Collapse
 
jsmanifest profile image
jsmanifest

Thank you Cole! Very motivating for me to hear :)