educational0 min read

Deconstructing toasts

Intro

Toast messages are one way to communicate to a user that something that changed. Usually these messages display for a few seconds and disappear on their own. Some configurable options could be:

  • Intent (informational, warning, error, etc)
  • Dismissible (auto disappear or click to control)
  • Position (which area of the screen to display the toast)
  • Duration (how long the message appears)
  • Content (message, details, image)

Three slices of golden toast on their side

When displaying a toast message, we typically want the message to animate in a certain way. To do this, there are a few libraries that can help with this so that the toast still animates in even if it's mounting/dismounting.

But what if the section for the toast message is already in the DOM and we want to recreate this logic ourselves?

Initial setup

Let's say we want to trigger our toast when the user has clicked a button to submit a form. We'll create a function called submitForm that will be triggered when our button is clicked.

import React from 'react'

export default function App() {
  return <button onClick={() => submitForm()}>Complete application</button>
}

Now that we've done that, let's set up our submitForm function.

import React, { useState, useCallback } from 'react'

export default function App() {
  const [toast, setToast] = useState(false)

  return <button onClick={() => submitForm()}>Complete application</button>
}

Generally the first thing you want to do is determine what state logic you need. In our case, we want to store a state that captures if the toast is showing up or not.

Our useState logic is saying that upon component render, this state is false. On its own, this doesn't mean anything but we'll use this boolean to determine if the toast should be visible.

Triggering the toast

import React, { useState, useCallback } from 'react'

export default function App() {
  const [toast, setToast] = useState(false)

  const submitForm = useCallback(() => {
    setToast(true)

    setTimeout(() => {
      setToast(false)
    }, 1000)
  }, [])
}

One thing to notice is that we are using useCallback here to reference a few methods. Because I want this component to disappear after a few seconds, I want to:

  • Set my toast state to false
  • Use setTimeout, which is a method that does something after a given amount of time that I specify.

Using CSSTransition

Now, we could just use CSSTransition to render our toast, but the point of this post is learning how to do some of this logic ourselves! If we were to use this library, we would do something like this, and add the corresponding toast-* classes.

<CSSTransition in={toast} timeout={180} classNames="toast">
  <Toast message="Howdy!" />
</CSSTransition>

A simple toast component

Let's make a few assumptions and simplify the component so all we need to worry about is passing in a message prop.

const ToastContext = createContext(null)

const Toast = ({ message }) => {
  const toast = useContext(ToastContext)
  return (
    <div className={makeClass(styles.toastWrapper, { [styles.shown]: toast })}>
      <span className={styles.toastContainer}>{message}</span>
    </div>
  )
}

This className logic is saying that when my toast state = true, assign a className of shown.

You will also notice the ToastContext I had to create. This is because I wanted to pass the toast state from my App parent to my Toast without have to explicitly pass it in as a prop. I could have chosen not do this and made an explicit prop that handles this, but chose not to for this demo to show how this works.

<ToastContext.Provider value={toast}>
  <Toast message="Congratulations, you did it!" />
  <button onClick={() => submitForm()}>Complete application</button>
</ToastContext.Provider>

Toast SCSS Modules

Here is some basic CSS that will have the toast appear at the top of the screen, using .toastWrapper as our backdrop/page, and .toastContainer for the toast itself.

Note that this is using a SCSS module vs regular CSS. This allows us to use the capabilities of SCSS but with the safety of CSS modules.

We also want to be mindful of folks who have reduced-motion turned on, and assign the appropriate media queries. In our case, we want the toast to fade in but not transition from the top of the screen.

.toastWrapper {
  display: flex;
  justify-content: center;
  position: fixed;
  left: 0;
  width: 100%;
  opacity: 0;
  top: 0;
  transition: opacity 0.1s ease-in;

  &.shown {
    opacity: 1;

    .toastContainer {
      top: 40px;
    }
  }
}

.toastContainer {
  transition: top 0.1s ease-in;
  position: absolute;
  top: 0;
  border-radius: 9999px;
  background-color: #333;
  font-family: sans-serif;
  color: #fff;
  margin-inline: 20px;
  padding: 16px 20px;

  @media (prefers-reduced-motion) {
    transition: none;
  }
}

.app {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100vh;
}

Testing it out

Now, if I run this code, when I click my button, the toast should appear and then disappear on its own after 1 second. This is great! But what if I repeatedly click the button immediately again while the toast is visible?

Whoops, it looks like the timer isn't resetting when we click the button, and that's not quite what we want. The reason this works with something like CSSTransition is because the className is reset on click, which triggers a state change. Because the state isn't changing onClick it doesn't know to reset our timer.

Resetting our timer

The first thing we want to do is clear out our timer when we click the button.

We can use clearTimeout which would cancel out our setTimeout function. Before we assign a variable to our setTimeout so we can reference it, we also need to think about how we determine when this logic should be triggered.

We could define a state, and do something like:

const [timer, setTimer] = useState<NodeJS.Timeout | null>(null)

Except this approach isn't ideal because useState triggers a rerender, and we don't really need to update the state necessarily to reset this timer.

Using useRef

What we can do is pass in a ref with useRef, because all we really want to do is reference the initialState which we can access via the current value.

We can rework our setTimeout logic to now be:

const timer = useRef(null)

if (timer.current) {
  clearTimeout(timer.current)
  timer.current = null
}

timer.current = setTimeout(() => {
  setToast(false)
}, 2000)

Putting it all together

See a full working demo in CodeSandbox!

Wrapping up

While using a library is probably more efficient, I thought it was fun to be able to simulate the same effect from scratch. By using a combination of useState, useContext, useCallback, and useRef, our toast component animates in and out just like we'd expect.

Not too comfortable with Hooks? I wrote an intro to using Hooks post.