Toasts

Toasts can be used to show contextual messages to the user that are not part of the page. A good example is showing an error message if a request to the backend fails. Another would be showing a success message after the user changes their password - as there is no direct confirmation via the page content.

Firstly lets create a ToastContext that we can use throughout the app to add toasts as required by adding the following to frontend/src/ToastContext.tsx,

import { Color } from "@material-ui/lab/Alert";
import React from "react";

export interface IToast {
  category?: Color;
  message: string;
}

interface IToastContext {
  addToast: (toast: IToast) => void;
  setToasts: React.Dispatch<React.SetStateAction<IToast[]>>;
  toasts: IToast[];
}

export const ToastContext = React.createContext<IToastContext>({
  addToast: () => {},
  setToasts: () => {},
  toasts: [],
});

interface IProps {
  children?: React.ReactNode;
}

export const ToastContextProvider = ({ children }: IProps) => {
  const [toasts, setToasts] = React.useState<IToast[]>([]);

  const addToast = (toast: IToast) => {
    setToasts((toasts) => [...toasts, toast]);
  };

  return (
    <ToastContext.Provider value={{ addToast, setToasts, toasts }}>
      {children}
    </ToastContext.Provider>
  );
};

We'll use the Material-UI snackbar to display toasts, and the Material-UI Alert to style the snack depending on the category (error, success, etc). The Alert is currently part of the Material-UI lab that can be installed via npm,

Run this command in frontend/

npm install --save @material-ui/lab

We can then create a Toasts component to display them. Note that only one toast should be displayed at any point in time, so a useEffect is setup to take and display a toast whenever there are toasts to display and there isn't an open one. The following should be added to frontend/src/components/Toasts.tsx,

import Snackbar from "@material-ui/core/Snackbar";
import Alert from "@material-ui/lab/Alert";
import React, { useEffect } from "react";

import { ToastContext, IToast } from "src/ToastContext";

const Toasts = () => {
  const { toasts, setToasts } = React.useContext(ToastContext);
  const [open, setOpen] = React.useState(false);
  const [currentToast, setCurrentToast] = React.useState<IToast | undefined>();

  useEffect(() => {
    if (!open && toasts.length) {
      setCurrentToast(toasts[0]);
      setToasts((prev) => prev.slice(1));
      setOpen(true);
    }
  }, [open, setCurrentToast, setOpen, setToasts, toasts]);

  const onClose = (event?: React.SyntheticEvent, reason?: string) => {
    if (reason !== "clickaway") {
      setOpen(false);
    }
  };

  return (
    <Snackbar
      anchorOrigin={{
        horizontal: "center",
        vertical: "top",
      }}
      autoHideDuration={6000}
      onClose={onClose}
      onExited={() => setCurrentToast(undefined)}
      open={open}
    >
      <Alert
        onClose={onClose}
        severity={currentToast?.category}
        variant="filled"
      >
        {currentToast?.message}
      </Alert>
    </Snackbar>
  );
};

export default Toasts;

Finally we can add the following to src/App.tsx to enable toasts in the app,

...
import Toasts from "src/components/Toasts";
import { ToastContextProvider } from "src/ToastContext";

const App = () => {
  ...
  return (
    <ToastContextProvider>
      <Toasts />
      ...
    </ToastContextProvider>
  );
}