Custom snackbar with notistack

Long time no see.

Almost a year ago I had to build a notification system for an application. And a college recommended the use of Notistack which integrated well with its React Native project and that's what drove him to suggest it.

I glued all the pieces together, and had a working notification system in a very short time. However, the notifications didn't look good with the rest of the application.

Notistack has a section in their documentation specifically for custom snackbars so you can integrate them with the overall design of your application. By default, Notistack snackbar's design follows a Material Design style. So, perhaps you don't have to customize much and get away with it by modifying a few bits here and there in ten lines of css or override the styles with your own classes. However, you don't always have that luxury and need to revamp the whole thing.

Let's start by reviewing how Notistack works and which options give us to achieve our goal.

For starters, we need to render a context provider which will store the state of notifications. This provider, which is called SnackbarProvider, should wrap our application or the parts of the application that are going to display them. The documentation warns us that if we are using MaterialUI, and want to leverage its styles, the context provider should live under MaterialUI's ThemeProvider. I used Styled Components, so the SnackbarProvider should be a child of Styled Components ThemeProvider.

Let's say we have a component called App which encapsulates our whole application and routes. Then, SnackbarProvider should wrap our App component.

// Root.tsx

import { SnackbarProvider } from "notistack";

export function Root() {
  return (
    <SnackbarProvider>
      <App />
    </SnackbarProvider>
  );
}

Perfect.

Next, we have to execute a function that will show the snackbar. Let's make use of the hook that will use the snackbar context and destructure the method enqueueSnackbar which will tell the snackbar context that we want to display a new snackbar.
The method accepts two parameters. The first one is a message like for example "User created", and the second parameter is an object of options for the snackbar being queued. This data will be fed into the snackbar context and push it into an array of snackbars and based on the stack of snackbars it will display the snackbar added.

In our App component we could have something like this:

// App.tsx
import { useSnackbar } from "notistack";

export function App() {
  const { enqueueSnackbar } = useSnackbar();

  function showSnackbar() {
    enqueueSnackbar("User created", { variant: "success" });
  }

  return <button onClick={showSnackbar}>Show snackbar</button>;
}

And when the button is clicked, the snackbar will be shown.

Now, the issue comes when we want to have a completely different component for the snackbar than the default one so we can have more flexibility.

Let's create our own snacbkar component.

// snackbar.tsx
import { useSnackbar } from 'notistack';

interface Props {
  id: string;
  message: string;
  variant: 'success' | 'error';
}

export function Snackbar({ id, message, variant }: Props) {
  const { closeSnackbar } = useSnackbar();
  const handleCloseSnackbar = () => closeSnackbar(id);

  return (
    <SnackbarContent>
      <Container>
        {variant === 'success' ? <CheckIcon /> : <TimesIcon />}
        <MessageContainer>
          {message}
        </MessageContainer>
        <DismissButton onClick={handleCloseSnackbar}>
          <ScreenReaderOnlyText>Close snackbar<ScreenReaderOnlyText>
          <CloseIcon aria-hidden />
        </DismissButton>
      </Container>
    </SnackbarContent>
  );
}

With this in place we can use it as our default snackbar component as notistack's custom snackbar documentation section says

We go back to our Root component where the SnackbarProvider is living:

// Root.tsx
import { SnackbarProvider } from "notistack";
import { Snackbar } from "components/Snackbar";

export function Root() {
  return (
    <SnackbarProvider
      content={(key, message) => <Snackbar id={key} message={message} />}
    >
      <App />
    </SnackbarProvider>
  );
}

But. Wait. Where is the variant prop?

Yes.

The render function does not receive more arguments than those, only key and message. So, we're done here right?

No. Let's come up with a solution. If we take a closer look to the documentation, the enqueueSnackbar function's second argument is an options object and between those options there is a content prop. Therefore, we can make our own custom hook calling the enqueueSnackbar function but providing our own snackbar component with the options we want. This custom hook should accept the same parameters as the enqueueSnackbar function but extending the functionality and the types of them.

// useEnqueueSnackbar.tsx
import { useSnackbar as useDefaultSnackbar, OptionsObject } from "notistack";
import { Snackbar } from "components/Snackbar";

export const useEnqueueSnackbar = () => {
  const { enqueueSnackbar } = useDefaultSnackbar();

  const pushSnackbar = (
    message: string,
    // extend the default options object
    options?: OptionsObject &
      Partial<{ variant: "success" | "error" | "warning" }>
  ) => {
    enqueueSnackbar(message, {
      ...options,
      content: (key) => {
        // destructure the options we need from the extended options
        // object, and provide a default case if we didn't provide any
        const { variant } = options || { variant: undefined };
        return (
          <Snackbar
            id={`${key}`}
            message={message}
            variant={variant || "success"}
          />
        );
      },
    });
  };

  return pushSnackbar;
};

Perfect. This is looking good. Now, instead of using the snackbar context, we can import this hook, execute it to get the callback and run the callback in the place we want to show the snackbar.

Moving to our App component we should do this:

// App.tsx
import { useEnqueueSnackbar } from "hooks/useEnqueueSnackbar";

export function App() {
  const enqueueSnackbar = useEnqueuSnackbar();

  function showSnackbar() {
    enqueueSnackbar("User created", { variant: "success" });
  }

  return <button onClick={showSnackbar}>Show snackbar</button>;
}

And this should show our own snackbar component with the variant prop provided.

Phew! That was a short but intense journey.

When I stumbled upon this problem I looked into notistack's repository and found out that this was actually an issue that was being tracked.

I provided this article's solution as a comment in the PR that attempted to fix this problem so anyone could benefit from it.

References: