App-wide Notifications with Hooks & React Context

Nearly all web apps require some type of system-wide user notification mechanism — be it alerts, error messages, success acknowledgments, or other use cases. In some older server-side frameworks, such UI functionality might’ve been referred to as flash messages, often rendered in the form of stacked, top-level banners. More recently, however, it’s often simple pop-up cards, as is the case in Tatari’s Media Buying Platform.

Our Requirements

While visuals may vary, these systems typically share some common criteria. In our MBP product, we needed to:

  1. Easily trigger a notification from anywhere in the app
  2. Support more than one notification card at a time (queueable)
  3. Ensure that live notification(s) survive any navigation changes (persisting)
  4. Close most notifications on a timeout (or manually prior to timeout expiration)
  5. Support a number of notification variants (alert, error, success)

Given these specs, clearly some type of app-wide state was required to store and manage these notifications. As an additional requirement, we also wanted this to be a fairly stand-alone abstraction, so it’s portable and easily testable.

Our Stack

At Tatari, our front end stack is written in Typescript, using React and GraphQL, of course! This being the case, we don’t have some uber state management solution, e.g. Redux or MobX. Rather, we make judicious use of the GraphQL cache, query string params, React Context, and/or local state. In fact, React Context and local state are just what the doctor ordered for our notifications system.

The Basics

React Context is great for sharing static data across an app, such as CSS theming variables, localizations, etc. It gives us a simple pub/sub-style Context Provider.

NotificationsContext.tsx

import * as React from 'react';

export const NotificationsContext = React.createContext<string[]>([]);

We can mount the Provider anywhere within the component hierarchy, typically near the root of the application tree, and supply it with any arbitrary data:

index.tsx

import * as React from 'react';
import Header from './components/Header';
import {NotificationsContext} from './context/NotificationsContext';

export default function App() {
  const notifications = ['Welcome to Tatari!'];
  return (
    <NotificationsContext.Provider value={notifications}>
      <Header />
    <NotificationsContext.Provider/>
  );
}

Subsequently, we can consume said data in any descendant component via React’s handy useContext hook. Say we’re going old-school, so we render the notifications as banners underneath our header:

Header.tsx

import * as React from 'react';
import Banner from './components/NotificationBanner';
import {NotificationsContext} from './context/NotificationsContext';

export default function Header() {
  const notifications = React.useContext(NotificationsContext);
  return (
    <>
      <header>
        Tatari
      </header>
      {notifications.map((message, i) => <Banner key={`message-${i}`}>{message}</Banner>)}
    </>
  );
}

Implementation

The above demonstrates React Context which serves as the foundation for our notifications system. However, we also need the ability to update the notifications state and define our messages with more metadata, so we can identify and style them uniquely.

Luckily, the Provider is just a React component, so we can enclose it in a custom stateful component and package any methods into our final Context API. A simplified implementation of our solution looks like this:

NotificationsContext.tsx

import * as React from "react";

export enum NOTIFICATION {
  ALERT,
  ERROR,
  SUCCESS
}

interface AddNotification {
  message: string | React.ReactNode;
  variant?: NOTIFICATION;
}

interface Notification {
  id: number;
  message: string | React.ReactNode;
  variant: NOTIFICATION;
}

const defaultApi = {
  notifications: [] as Notification[],
  setNotification: (notification: AddNotification) => null,
  clearNotification: (id: number) => null
};

export type NotificationsContextValue = typeof defaultApi;

/**
 * Create Context
 */
export const NotificationsContext = React.createContext<
  NotificationsContextValue
>(defaultApi);

/**
 * Custom Notifications Provider
 */
export function NotificationsProvider({ children }: any) {
  // Notifications queue is managed in local useState
  const [notifications, setNotifications] = React.useState<Notification[]>(
    defaultApi.notifications
  );

  // Method to push a new notification
  const setNotification = React.useCallback(
    (notification: AddNotification) => {
      const nextNotifications = notifications.concat({
        id: new Date().getTime(), // we use UIDs in the final ver
        variant: NOTIFICATION.ERROR,
        ...notification
      } as Notification);
      setNotifications(nextNotifications);
    },
    [notifications, setNotifications]
  );

  // Method to clear a notification
  const clearNotification = React.useCallback(
    (id: number) => {
      const nextNotifications = notifications.filter((n) => n.id !== id);
      setNotifications(nextNotifications);
    },
    [notifications, setNotifications]
  );

  // Return Provider with full API
  const api = { notifications, setNotification, clearNotification };
  return (
    <NotificationsContext.Provider value={api}>
      {children}
    </NotificationsContext.Provider>
  );
}

Once we drop this improved Provider into the app, we can trigger and clear notifications everywhere by using its API methods. And since we’ve already ventured into the land of React Hooks, we might as well create a convenience hook for importing the API. It doesn’t really cut down on code but hey, one can never have too many hooks!

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

To test drive this prototype, head over to Code Sandbox.

Bells & Whistles

Rudimentary visuals aside, this implementation works pretty well as a demo, but our exacting PMs and designers have higher standards, naturally! Thankfully, with good bones, it’s an easy system to expand upon. Here are the improvements:

  • Ability to update a live notification by setting a notification of the same id
  • Ability to clear multiple, or all, notifications at once
  • Automatic timeout of notifications, with an optional custom timeout (or no timeout)
  • An animating stack of notification cards fading in/out (via React Spring)

For an implementation resembling our final product*, peruse this Code Sandbox.

Conclusion

We are pleased with our solution as it has proven to be simple yet flexible. As you may have noticed in the final demo, the rendering of the cards is handled by a completely separate component, so you can easily adapt any part of the system as you see fit!

Learning to work with React Hooks has been an interesting journey and we’ve now built enough such abstractions to be dangerous: query string-based filter states, a keyboard shortcuts matcher, a form library, etc. Future blog posts perhaps?

In the meantime, happy notifying!

[*] Our notifications accept an additional ‘details’ prop and the cards have more involved styling; we rely on rem sizing and, you guessed it — an app-wide theme context! :)

Peter Morawiec

Peter Morawiec

Peter is a Sr. Frontend Developer at Tatari