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