DEV Community

Guilherme Oderdenge
Guilherme Oderdenge

Posted on • Edited on

Creating a global notifications system with superstate and React

Greetings, developers!

I'd like to show you my take on how to build a simple global notifications system with superstate and React.

We have one extra, implicit goal: building something with satisfying ergonomics and developer experience.

With no further ado, shall we?


If you prefer, there's also a video of this guide available!


Prerequisites

I'm going to create a brand new create-react-app application with TypeScript:

yarn create react-app superstate-notifications --template typescript
Enter fullscreen mode Exit fullscreen mode

Note that I am using yarn, but you can mimic my commands using npm as well.

Once it's done, let's move our working directory to the superstate-notifications application we just created:

cd superstate-notifications
Enter fullscreen mode Exit fullscreen mode

And then let's install superstate:

yarn add @superstate/core
Enter fullscreen mode Exit fullscreen mode

Cool. Now we have a project that's good to go.

What is superstate?

superstate hero image

In short, superstate is a micro state management library for JavaScript applications. Despite the nuances, you can think of it as an alternative solution for Redux or Zustand.

It was designed with developer wellness in mind, and comes bundled with a powerful and handy drafts system to make our lives easier and less repetitive.

Getting started

Now that you have a working project to get your hands dirty, let's create a notifications.tsx file within src/ and bootstrap the state of our notifications:

import { superstate } from '@superstate/core'

const notifications = superstate([])
Enter fullscreen mode Exit fullscreen mode

Note the [] within superstate(). That's the initial value of your state. It's as if you'd have typed:

const notifications = []
Enter fullscreen mode Exit fullscreen mode

Except that you wrapped the empty array inside a superstate, and that gives us powers.

Creating & destroying notifications

The next step is creating the two most important functions of the notifications feature: notify and destroy. Respectively, one is meant to issue new notifications and the other is to destroy them.

This is what I came up with:

function notify(message: string) {
  const id = Math.random().toString()

  notifications.set((prev) => [...prev, { id, message }])
}

function destroy(id: string) {
  notifications.set((prev) => prev.filter((p) => p.id !== id))
}
Enter fullscreen mode Exit fullscreen mode

The notify function

It expects to receive a message (of type string) argument. This message is what the user is going to see once the notification pops up.

Also, this function declares an id variable and assigns Math.random().toString() to it. This is just because we want our system to support multiple notifications at once, and we must have a way to differentiate one notification from another—id is the way.

Furthermore, the notify function calls .set() from our notifications object. If you scroll up a little, you're going to notice this notifications object is our superstate() variable, thus .set() is a function returned from it.

It may look complicated at first, but all we're doing is passing to .set() a function that returns what the list of notifications should look like once we emit this new one.

prev is the previous value of notifications. Initially, the value of notifications is [] (an empty array), but as we start emitting notifications, this array will eventually grow—so prev ensures that we're adding new notifications instead of replacing them.

Look at what we are doing again:

notifications.set((prev) => [...prev, { id, message }])
Enter fullscreen mode Exit fullscreen mode

It means the next value of notifications is the former notifications plus the new one, which is represented by an object with the id and message properties.

The destroy function

Here we are telling that the next value of notifications is all notifications but the one that matches the specified id passed through the argument of the destroy function:

notifications.set((prev) => prev.filter((p) => p.id !== id))
Enter fullscreen mode Exit fullscreen mode

Rendering notifications

Now in this same notifications.tsx file, let's create a Notifications Renderer. Its job is critical: displaying the notifications to the user.

Here's the bootstrap of it:

export function NotificationsRenderer() {
  useSuperState(notifications)

  return null
}
Enter fullscreen mode Exit fullscreen mode

Wait, what? Where is this useSuperState() function coming from?

Yeah, I did not mention it so far. Intentionally. In order to integrate superstate with React, you have to install an extra dependency:

yarn add @superstate/react
Enter fullscreen mode Exit fullscreen mode

And import it in your notifications.tsx file:

import { useSuperState } from '@superstate/react'
Enter fullscreen mode Exit fullscreen mode

The useSuperState hook re-renders our component (NotificationsRenderer) every time the state passed to it changes. In our context, this "state passed to it" refers to notifications.

This is what I came up with to make the renderer fully functional:

export function NotificationsRenderer() {
  useSuperState(notifications)

  if (!notifications.now().length) {
    return null
  }

  return (
    <div>
      {notifications.now().map((n) => {
        return (
          <div key={n.id}>
            <p>{n.message}</p>

            <button onClick={() => destroy(n.id)}>
              Destroy
            </button>
          </div>
        )
      })}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Let's break it down:

if (!notifications.now().length) {
  return null
}
Enter fullscreen mode Exit fullscreen mode

The if above guarantees that nothing will be rendered when no notifications exist. Note the now() method—it returns the current value of your notifications array. The condition states that if there are no items in the notifications list, then we'd like to render null.

{notifications.now().map((n) => {
Enter fullscreen mode Exit fullscreen mode

The line above will iterate over each item in the notifications array and return something. In our context, for each notification, something will be rendered. Note that now() is present again.

return (
  <div key={n.id}>
    <p>{n.message}</p>

    <button onClick={() => destroy(n.id)}>
      Destroy
    </button>
  </div>
)
Enter fullscreen mode Exit fullscreen mode

The lines above refer to the actual notification item that will be rendered in the browser and displayed to the user.

As of the last piece of the rendering puzzle, let's open ./src/App.tsx and clear the returned component to look something like this:

export default function App() {
  return ()
}
Enter fullscreen mode Exit fullscreen mode

With the house clean, we can now render our renderer:

import { NotificationsRenderer } from './notifications'

export default function App() {
  return (
    <div>
      <NotificationsRenderer />

      <button>Give me a notification!</button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Emitting notifications

You may have noticed we created a Give me a notification! button at the above post section but have done nothing with it. Well, yet.

Let's make it give us a notification whenever it is clicked:

<button onClick={() => notify('Hello world!')}>
  Give me a notification!
</button>
Enter fullscreen mode Exit fullscreen mode

The notify function won't work right away. We first have to export it. Go back to notifications.tsx and export both notify and destroy functions by prepending the export keyword in front of the function keyword:

export function notify(message: string) {
  const id = Math.random().toString()

  notifications.set((prev) => [...prev, { id, message }])
}

export function destroy(id: string) {
  notifications.set((prev) => prev.filter((p) => p.id !== id))
}
Enter fullscreen mode Exit fullscreen mode

Now, at App.tsx, you'll be able to import them:

import {
  notify,
  destroy,
  NotificationsRenderer,
} from './notifications'
Enter fullscreen mode Exit fullscreen mode

And boom! Save all your files and go to your browser to play with your fresh notifications system. :)

Wrapping up

Your final notifications.tsx should look like this:

import { superstate } from '@superstate/core'
import { useSuperState } from '@superstate/react'

const notifications = superstate([])

export function notify(message: string) {
  const id = Math.random().toString()

  notifications.set((prev) => [...prev, { id, message }])
}

export function destroy(id: string) {
  notifications.set((prev) => prev.filter((p) => p.id !== id))
}

export function NotificationsRenderer() {
  useSuperState(notifications)

  if (!notifications.now().length) {
    return null
  }

  return (
    <div>
      {notifications.now().map((n) => {
        return (
          <div key={n.id}>
            <p>{n.message}</p>

            <button onClick={() => destroy(n.id)}>
              Destroy
            </button>
          </div>
        )
      })}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

And your App.tsx:

import {
  notify,
  destroy,
  NotificationsRenderer,
} from './notifications'

export default function App() {
  return (
    <div>
      <NotificationsRenderer />

      <button onClick={() => notify('Hello world!')}>
        Give me a notification!
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

You can see a slightly fancier example on StackBlitz:

Final thoughts

This is a pretty basic notifications system, but quite powerful and intuitive. Now, to dispatch notifications in your app, all you have to do is calling the notify() function you created yourself from anywhere in your app, including non-React code, and have fun because things will just work.

Now you go have some fun and don't hesitate to reach out with any questions or feedback! d(^_^)z

Top comments (0)