How to Make Headless React Notifications in TypeScript
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;
}
prop | description |
---|---|
id | A unique identifier. |
duration | The duration the notification will be active for. If undefined, the notification needs to be closed manually. |
active | Should the notification be shown to the user? |
message | The 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[];
}
prop | description |
---|---|
add | Adds a notification to the screen. We omit active because it's managed internally. |
remove | Removes a notification from the screen. |
items | Returns an array of all notifications. |
Next, we'll define a global configuration for our notification system.
interface NotificationConfig {
maxNotifications?: number;
}
prop | description |
---|---|
maxNotifications | The 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.
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.
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.