Search code examples
reactjsnext.jsmaterial-uisnackbareasy-peasy

React Material-UI Snackbar component - storing multiple snackbars in state


I am using the React MUI library with NextJS

I am trying to create a function where snackbars generated are stored in an array within the store (using a redux like library called Easy-Peasy) and then shown one by one as they timeout.

E.g. if there are 3 snackbars due to three errors the user made - then:

  1. First snackbar will show - then timeout after 4 seconds

  2. Second will show and then a 4 second time out

  3. Third will show and then time out after 4 seconds

If one snackbar is generated then it will work normally, i.e. disappear once it times out.
If there are two or more snackbars, then it will cause only one to disappear leaving behind n-1 snackbars, e.g. if there are 3 in the array, one will disappear leaving two in the queue. The second one will never disappear via timeout.

import { useState, useEffect } from 'react'
import { Snackbar, Alert } from '@mui/material'

export default function SnackBar(props) {
  const snackbarData = []
  // this will be an array from the store which is structured: 
  // [
  //   { id: 1, message: 'message1', severity: 'error' },
  //   { id: 2, message: 'message2', severity: 'error' }
  // ]
  const removeSnackbarMessage = () => { 
    // a function which performs a .shift() method on the snackbarData array within the store 
  }

  const [open, setOpen] = useState(false)
  const [message, setMessage] = useState('')
  const [severity, setSeverity] = useState('')

  const handleClose = (event, reason) => {
    if (reason === 'clickaway') { return }
    setOpen(false)
    removeSnackbarMessage()
  }

  useEffect(() => {
    if (snackbarData.length > 0) {
      setOpen(true)
      setMessage(snackbarData[snackbarData.length - 1].message)
      setSeverity(snackbarData[snackbarData.length - 1].severity)
    } else {
      setOpen(false)
      setMessage('')
      setSeverity('')
    }
  }, [snackbarData, snackbarData.length])
    
  return (
    <Snackbar 
      open={open} 
      autoHideDuration={4000} 
      anchorOrigin={{ horizontal: 'right', vertical: 'bottom'}}
      onClose={handleClose}
    >
      <Alert severity={severity}>{message}</Alert>
    </Snackbar>
  )
}

Above is the code that is contained within the <SnackBar /> component, which is referenced within index.js.

Any help is appreciated. TIA.


Solution

  • The issue is the messages you want to "queue" are just overwriting the single message stored in state. You need to create a queue (array) of messages but cannot use the default autoHideDuration property in this case because that's only meant for a single message.

    Here's a working example of a queued Snackbar notification system using React18 and MUI which you can tie-into a store.

    index.js

    import { StrictMode } from "react";
    import { createRoot } from "react-dom/client";
    
    import App from "./App";
    
    const rootElement = document.getElementById("root");
    const root = createRoot(rootElement);
    
    root.render(
      <StrictMode>
        <App />
      </StrictMode>
    );

    App.js

    import "./styles.css";
    import { useState } from "react";
    import Button from "@mui/material/Button";
    import Snackbar from "./Snackbar";
    
    export default function App() {
      // create some initial error messages, which already worked fine
      const [snackbarData, setSnackbarData] = useState([
        { id: 1, message: "First default message", severity: "error" },
        { id: 2, message: "Second default message", severity: "error" },
        { id: 3, message: "Third default message", severity: "error" },
      ]);
    
      // create a random error for the snackbar queue, this normally caused the issue
      const createError = () => {
        setSnackbarData((d) => [
          ...d,
          {
            id: 3,
            message: `Error Message ${Math.random()}`,
            severity: "error",
          },
        ]);
      };
    
      return (
        <div className="App">
          <Button variant="contained" color="error" onClick={createError}>
            Create Error
          </Button>
          <Snackbar snackbarData={snackbarData} setSnackbarData={setSnackbarData} />
          {snackbarData.length ? (
            <div style={{ marginTop: "1rem" }}>
              <b>Queued messages</b>
              {snackbarData
                .filter((_i, key) => key > 0)
                .map((msg, key) => (
                  <div
                    key={`msg-${key}`}
                    style={{ borderTop: "1px solid black", margintop: ".25rem" }}
                  >
                    Message: {msg.message}
                    {"  "}
                    {msg.severity}
                  </div>
                ))}
            </div>
          ) : null}
        </div>
      );
    }

    Snackbar.js

    import { useState, useEffect } from "react";
    import Alert from "@mui/material/Alert";
    import Snackbar from "@mui/material/Snackbar";
    
    export default function SnackBar(props) {
      const { snackbarData, setSnackbarData } = props;
    
      const [open, setOpen] = useState(false);
      const [timer, setTimer] = useState();
    
      // drop the first message, since it's just finished showing
      const removeSnackbarMessage = () => {
        console.log("removeSnackbarMessage");
        setSnackbarData((s) => s.filter((_i, key) => key > 0));
      };
    
      // close snackbar and empty messages (if force closed)
      const handleClose = (_event, reason) => {
        if (reason === "clickaway") {
          return;
        }
        setOpen(false);
        setSnackbarData([]);
      };
    
      // open or close the snackbar depending on if there is data.
      useEffect(() => {
        setOpen(!!snackbarData.length);
      }, [snackbarData]); // dont need .length as a dependency
    
      // whenever snackbar is toggled, initiate the queue and check on it.
      useEffect(() => {
        if (open) {
          console.log("SET NEW INTERVAL!");
          const interval = setInterval(() => {
            // no more data? stop the timer.
            if (!snackbarData.length) clearInterval(timer);
            removeSnackbarMessage();
          }, 4000);
          setTimer(interval);
        }
        return () => clearInterval(timer);
      }, [open]);
    
      // grab the first message in queue, if any exist
      const firstMsg = snackbarData.length ? snackbarData[0] : null;
    
      // show nothing
      if (!firstMsg) return null;
    
      return (
        <Snackbar
          open={open}
          // autoHideDuration={4000}
          anchorOrigin={{ horizontal: "right", vertical: "bottom" }}
          onClose={handleClose}
        >
          <Alert severity={firstMsg?.severity}>{firstMsg?.message}</Alert>
        </Snackbar>
      );
    }

    package.json

    {
      "name": "react",
      "version": "1.0.0",
      "description": "React example starter project",
      "keywords": [
        "react",
        "starter"
      ],
      "main": "src/index.js",
      "dependencies": {
        "@emotion/react": "^11.10.5",
        "@emotion/styled": "^11.10.5",
        "@mui/material": "^5.11.8",
        "react": "18.2.0",
        "react-dom": "18.2.0",
        "react-scripts": "^5.0.1",
        "typescript": "^4.9.5"
      },
      "devDependencies": {
        "@babel/runtime": "7.13.8"
      },
      "scripts": {
        "start": "react-scripts start",
        "build": "react-scripts build",
        "test": "react-scripts test --env=jsdom",
        "eject": "react-scripts eject"
      },
      "browserslist": [
        ">0.2%",
        "not dead",
        "not ie <= 11",
        "not op_mini all"
      ]
    }

    Working CodeSandbox:

    https://codesandbox.io/p/sandbox/react18-mui5-queued-snackbar-messages-k9v6rw

    Expected Functionality:

    Initially, it will load 3 messages and play through them, 4-seconds at a time. You can also queue up more messages by simply clicking the button.

    I hope that helps!