skies.dev

How to Make Headless React Notifications in TypeScript

7 min read

The problem with React notification libraries is that they do too much.

They are often pre-designed, require you to import their CSS, or has animation properties that never seem to fit your app. This is not ideal when you want to create a consistent design.

We'll build a notification state management library. The styling will not be opinionated since design requirements change from site to site.

Building a system for managing notification state

We'll start by defining what is a notification.

interface Notification {
  id: string;
  duration?: number;
  active: boolean;
  message: string;
}
propdescription
idA unique identifier.
durationThe duration the notification will be active for. If undefined, the notification needs to be closed manually.
activeShould the notification be shown to the user?
messageThe message we'll shown to the user.

In your app, you may want to define more properties for your notification. Go ahead! We'll stick with these four basic properties for the purpose of this explanation.

Now that we've defined the notification properties, let's create an API for managing notification state.

interface NotificationManager {
  add: (notification: Omit<Notification, 'active'>) => void;
  remove: (id: string) => void;
  items: Notification[];
}
propdescription
addAdds a notification to the screen. We omit active because it's managed internally.
removeRemoves a notification from the screen.
itemsReturns an array of all notifications.

Next, we'll define a global configuration for our notification system.

interface NotificationConfig {
  maxNotifications?: number;
}
propdescription
maxNotificationsThe maximum number of notifications allowed on the screen at any given time.

Feel free to remove or add your own config variables as you see fit.

We'll use React Context to make the notifications available throughout our app.

const NotificationContext = React.createContext<NotificationManager>({
  add: () => {},
  remove: () => {},
  items: [],
});

We'll export a custom React hook to access the NotificationManager API from anywhere in our app.

export function useNotifications() {
  return React.useContext(NotificationContext);
}

And finally, we'll implement the NotificationManager API via a Context provider.

export function NotificationsProvider({
  maxNotifications = 5,
  children,
}: NotificationConfig & {children: React.ReactNode}) {
  const [items, setItems] = React.useState<Notification[]>([]);

  return (
    <NotificationContext.Provider
      value={{
        add(notification: Omit<Notification, 'active'>) {
          const newItems = items.filter((i) => i.active);

          if (
            newItems.length < maxNotifications &&
            newItems.find(
              (item) => item.id === notification.id && item.active,
            ) !== undefined
          ) {
            const newItems = newItems.concat({...notification, active: true});
            setItems(() => newItems);

            if (notification.duration !== undefined) {
              setTimeout(() => {
                setItems(() =>
                  newItems.map((item) =>
                    item.id === notification.id
                      ? {...item, active: false}
                      : item,
                  ),
                );
              }, notification.duration);
            }

            return;
          }

          setItems(() => newItems);
        },

        remove(id: string): void {
          setItems(() =>
            items.map((item) =>
              item.id === id ? {...item, active: false} : item,
            ),
          );
        },

        items,
      }}
    >
      {children}
    </NotificationContext.Provider>
  );
}

There's a lot to unpack here, so let's take it piece by piece.

First, we're managing the notifications' state using React's useState API.

export function NotificationsProvider(
  {
    // ...
  },
) {
  const [items, setItems] = React.useState<Notification[]>([]);

  // ...
}

We then implement the add and remove methods.

remove soft-deletes the notification with the given id by setting that notification's active state to false.

export function NotificationsProvider(
  {
    // ...
  },
) {
  // ...

  return (
    <NotificationContext.Provider
      value={{
        // ...

        remove(id: string): void {
          setItems(() =>
            items.map((item) =>
              item.id === id ? {...item, active: false} : item,
            ),
          );
        },

        // ...
      }}
    >
      {children}
    </NotificationContext.Provider>
  );
}

As a side effect in add, we'll filter out inactive notifications, garbage collecting stale notifications, and preventing the items array from becoming unnecessarily large.

export function NotificationsProvider(
  {
    // ...
  },
) {
  // ...

  return (
    <NotificationContext.Provider
      value={{
        add(notification: Omit<Notification, 'active'>) {
          const newItems = items.filter((i) => i.active);

          // ...

          setItems(() => newItems);
        },

        // ...
      }}
    >
      {children}
    </NotificationContext.Provider>
  );
}

Recall the purpose of the active param is to allow animation libraries such as Headless UI to animate the notification when it mounts and unmounts.

To actually add a notification, we'll do the following checks:

  • Can we fit another notification i.e. is the number of notifications less than maxNotifications?
  • Are we not inserting a duplicate notification i.e. is there already an active notification with the same id?

If the conditions are met, we'll add the notification.

export function NotificationsProvider(
  {
    // ...
  },
) {
  // ...

  return (
    <NotificationContext.Provider
      value={{
        add(notification: Omit<Notification, 'active'>) {
          const newItems = items.filter((i) => i.active);

          if (
            newItems.length < maxNotifications &&
            newItems.find(
              (item) => item.id === notification.id && item.active,
            ) !== undefined
          ) {
            const newItems = newItems.concat({...notification, active: true});
            setItems(() => newItems);

            // ...

            return;
          }

          setItems(() => newItems);
        },

        // ...
      }}
    >
      {children}
    </NotificationContext.Provider>
  );
}

If we specified a duration, we'll set a timer to automatically soft-delete said notification.

export function NotificationsProvider(
  {
    // ...
  },
) {
  // ...

  return (
    <NotificationContext.Provider
      value={{
        add(notification: Omit<Notification, 'active'>) {
          const newItems = items.filter((i) => i.active);

          if (
            newItems.length < maxNotifications &&
            newItems.find(
              (item) => item.id === notification.id && item.active,
            ) !== undefined
          ) {
            const newItems = newItems.concat({...notification, active: true});
            setItems(() => newItems);

            if (notification.duration !== undefined) {
              setTimeout(() => {
                setItems(() =>
                  newItems.map((item) =>
                    item.id === notification.id
                      ? {...item, active: false}
                      : item,
                  ),
                );
              }, notification.duration);
            }

            return;
          }

          setItems(() => newItems);
        },

        // ...
      }}
    >
      {children}
    </NotificationContext.Provider>
  );
}

And that covers how notification state management in React works. Here's how you can put it to use.

How to use the notification system in your app

Declare the NotificationProvider at the top-most component in your React tree. This will allow you to use the useNotifications hook wherever in your app.

In a Next.js app, this would probably be in a Custom App component.

pages/_app.tsx
import type {AppProps} from 'next/app';
import {NotificationsProvider} from '@lib/notifications';

export default function MyApp({Component, pageProps}: AppProps) {
  return (
    <NotificationsProvider>
      <Component {...pageProps} />
    </NotificationsProvider>
  );
}

Then, if you have a common Layout component, you probably would want to render your notifications there.

In the next code example, I'll show how you would use the API to create, remove, and render notifications.

I omit CSS details because that will be unique to your app. The state management of notifications, however, should be consistent no matter what kind of app your building.

To keep things simple, I'm filtering out inactive notifications, but in your project you might want to use the active prop to animate the notification when it mounts and unmounts.

components/Layout.tsx
import {useNotifications} from '@lib/notifications';

export function Layout() {
  const notifications = useNotifications();

  return (
    <>
      <button
        onClick={() =>
          notifications.add({
            id: 'foo',
            duration: 5000,
            message: 'Showing how the notifications work!',
          })
        }
      >
        Add notification
      </button>
      <div aria-live="assertive">
        {notifications
          .filter((notification) => notification.active)
          .map((notification) => (
            <div key={notification.id}>
              <p>{notification.message}</p>
              <button onClick={() => notifications.remove(notification.id)}>
                Dismiss notification
              </button>
            </div>
          ))}
      </div>
    </>
  );
}

And with that, you should have the knowledge to create your own custom notifications in your React app.

Hey, you! 🫵

Did you know I created a YouTube channel? I'll be putting out a lot of new content on web development and software engineering so make sure to subscribe.

(clap if you liked the article)

You might also like