How to create a notification/toast system in React Typescript with Redux Toolkit, Tailwind and Framer Motion
Tags: webdev, typescript, react, tutorial
Published: 2022-01-30 19:33:47

In this short article we'll be building a Notification/Toast component.

The goal of this article is simply to provide inspiration on how to build a component like this. Nothing in this article is highly opinionated so please use another state manager, another file structure, another style system if you wish.  

This type of component has been described with many different names, and the different names might all signal various things to different people, but what this article will be addressing is a basic component that simply informs the user of something as a response to any given action e.g. updating profile information etc. 
 
You can find the finished demo and code beneath.

Demo: [Here](https://medium-notifications-demo.vercel.app/)
Github repository: [Here](https://github.com/christofferberg/medium-notifications-demo)

We will be building 4 variants of the Notification component – Success, Warning, Error and Info. 

The article will be a quick run through of code and so it is required to have basic knowledge on a modern React based development setup and the tools used, as I won't describe the different parts in depth. 

**Tools used:**

[Next.js](https://nextjs.org/docs/getting-started)
[Redux Toolkit](https://redux-toolkit.js.org/)
[Framer Motion](https://www.framer.com/motion/)
[Tailwind](https://tailwindcss.com/)
[Radix UI](https://www.radix-ui.com/)
[Radix colors](https://www.radix-ui.com/colors)
[react-use](https://github.com/streamich/react-use)
[clsx](https://github.com/lukeed/clsx)
[lodash](https://lodash.com/)
[ms](https://www.npmjs.com/package/ms)

```sh
npx create-next-app@latest --typescript name-of-project
```

---

## Basic setup and Redux Toolkit

After bootstrapping a Next.js project with typescript, we'll start by setting up Redux, and for this we'll be using the official, opinionated, batteries-included toolset for efficient Redux development: [Redux Toolkit](https://redux-toolkit.js.org/). 

From here on, create a `src` folder and inside `src` create an `app` folder, a `features` folder and then a `redux` folder. Also move the default Next.js `pages` folder inside `src`. 

This will be our basic project structure. 
It does not matter how you structure the project, or how you prefer to name folders – this is just a general baseline that I like to use. 

Each feature will be put into the `features` folder, and have its own components, hooks and Redux state slice. We will consider `Notifications` to be an app "feature". 

![Folder structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2qjuu9fni6uincncenh5.png)
 

Inside the `redux` folder we'll create 3 files: `hooks.ts`, `rootReducer.ts` and `store.ts`. 

These 3 files will contain our basic Redux setup.

The `store.ts` file will contain the basic setup of our global Redux store. It will contain our different reducers, and export different type helpers, that'll be used throughout the project. 

```ts
// src/redux/store.ts
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit'
import { rootReducer } from '@redux/rootReducer'

export const store = configureStore({
  reducer: rootReducer,
})

export type AppDispatch = typeof store.dispatch
export type RootState = ReturnType<typeof store.getState>
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  RootState,
  unknown,
  Action<string>
>
```

Notice the `@redux/rootreducer` import. tsconfig paths has been used for this. Please see the `tsconfig.json` and `tsconfig.paths.json` file in the repo. 

Now inside `rootReducer.ts` we'll setup our Redux root reducer, that will contain all of the different reducers one might create throughout a project. 

```ts
// src/redux/rootReducer.ts
import { combineReducers } from '@reduxjs/toolkit'

import notificationsReducer from '@features/notifications/notification.slice'

export const rootReducer = combineReducers({
  notifications: notificationsReducer,
})
```

The rootReducer is importing a `notificationsReducer` that hasn't been created yet. We will create this soon. 

Lastly inside `hooks.ts` we'll export general Redux hooks to use throughout the project. 

```ts
// src/redux/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from '@redux/store'

export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
```

The hooks are basically just adding type safety to regular Redux hooks. 

All of this basic setup can be found in the [Redux Toolkit documentation](https://redux-toolkit.js.org/tutorials/typescript). 



## Creating our Notifications Redux slice

Under `features` we'll create our `notifications` feature and inside that feature a `notifications.slice.ts` file, that will contain all the Redux logic for our toasts/notifications. 

We'll start by defining how our Notifications state should look like, and the state slice itself. 

```ts
// src/features/notifications/notifications.slice.ts
type NotificationsState = {
  notifications: Notification[]
}

const initialState: NotificationsState = {
  notifications: [],
}

const notificationsSlice = createSlice({
  name: 'notifications',
  initialState,
  reducers: {},
})
```

The `Notification` type that we use in the State slice will be defined in the Notification component itself later on. It looks like this: 

```ts
// src/features/notifications/NotificationItem.tsx
export type NotificationTypes = 'success' | 'error' | 'warning' | 'info'

export type Notification = {
  /**
   * The notification id.
   */
  id: string

  /**
   * The message of the notification
   */
  message: string

  /**
   * An optional dismiss duration time
   *
   * @default 6000
   */
  autoHideDuration?: number

  /**
   * The type of notification to show.
   */
  type?: NotificationTypes

  /**
   * Optional callback function to run side effects after the notification has closed.
   */
  onClose?: () => void

  /**
   * Optionally add an action to the notification through a ReactNode
   */
  action?: ReactNode
}

```

We'll then add our different reducers to handle adding/dismissing a notification. 

```ts
// src/features/notifications/notifications.slice.ts
const notificationsSlice = createSlice({
  name: 'notifications',
  initialState,
  reducers: {
    /**
     * Add a notification to the list
     *
     * @param state - Our current Redux state
     * @param payload - A notification item without an id, as we'll generate this.
     */
    addNotification: (
      state,
      { payload }: PayloadAction<Omit<Notification, 'id'>>
    ) => {
      const notification: Notification = {
        id: nanoid(),
        ...payload,
      }

      state.notifications.push(notification)
    },
    /**
     * Remove a notification from the list
     *
     * @param state - Our current Redux state
     * @param payload - The id of the Notification to dismiss
     */
    dismissNotification: (
      state,
      { payload }: PayloadAction<Notification['id']>
    ) => {
      const index = state.notifications.findIndex(
        (notification) => notification.id === payload
      )

      if (index !== -1) {
        state.notifications.splice(index, 1)
      }
    },
  },
})
```

We our reducer logic in place, we'll finish off the notifications state slice by creating and exporting a selector function to select the notifications state, and a hook to easily use it in our React components. 

We'll also export the reducer itself and the accompanying Redux actions. 

The full file looks like this: 

```ts
// src/features/notifications/notifications.slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { nanoid } from 'nanoid'

import type { Notification } from '@features/notifications/components/NotificationItem'
import type { RootState } from '@redux/store'
import { useAppSelector } from '@redux/hooks'

type NotificationsState = {
  notifications: Notification[]
}

const initialState: NotificationsState = {
  notifications: [],
}

const notificationsSlice = createSlice({
  name: 'notifications',
  initialState,
  reducers: {
    /**
     * Add a notification to the list
     *
     * @param state - Our current Redux state
     * @param payload - A notification item without an id, as we'll generate this.
     */
    addNotification: (
      state,
      { payload }: PayloadAction<Omit<Notification, 'id'>>
    ) => {
      const notification: Notification = {
        id: nanoid(),
        ...payload,
      }

      state.notifications.push(notification)
    },
    /**
     * Remove a notification from the list
     *
     * @param state - Our current Redux state
     * @param payload - The id of the Notification to dismiss
     */
    dismissNotification: (
      state,
      { payload }: PayloadAction<Notification['id']>
    ) => {
      const index = state.notifications.findIndex(
        (notification) => notification.id === payload
      )

      if (index !== -1) {
        state.notifications.splice(index, 1)
      }
    },
  },
})

const { reducer, actions } = notificationsSlice

// Actions
export const { addNotification, dismissNotification } = actions

// Selectors
const selectNotifications = (state: RootState) =>
  state.notifications.notifications

// Hooks
export const useNotifications = () => useAppSelector(selectNotifications)

export default reducer

```


## Create Notifications components

Under `src/features/notifications` create a `components` folder. This is where we'll be putting all components related to our Notifications feature. 

We will be creating 3 components. 

`Notifications.tsx`, `NotificationList.tsx` and lastly `NotificationItem.tsx`. 

![Notification components](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/38hye15g52az415f7boz.png)

Our Parent `Notifications.tsx` component will subscribe to our Notifications state slice, output the `NotificationList` component and map over the notifications list that lives inside our Redux slice to render multiple `NotificationItems` as `children` inside the `NotificationList`. 


### Notifications parent component

```tsx
// src/features/ntoifications/components/Notifications.tsx
import { useNotifications } from '@features/notifications/notification.slice'

import { NotificationItem } from '@features/notifications/components/NotificationItem'
import { NotificationList } from '@features/notifications/components/NotificationList'

export const Notifications = () => {
  const notifications = useNotifications()

  return (
    <NotificationList>
      {notifications.map((notification) => (
        <NotificationItem key={notification.id} notification={notification} />
      ))}
    </NotificationList>
  )
}

```

### Notifications list component

Our `NotificationList.tsx` component is a component that will hold all of our `NotificationItems`. It will utilise the React Portal concept to render the HTML in a different part of the DOM. I use the [Portal component from Radix UI](https://www.radix-ui.com/docs/primitives/utilities/portal). 

The portal appends to `document.body` by default but can be customized to use a different container.

Out `NotificationList` will also wrap our single Notification items with Framer Motion animation components, that will allow us to animate position changes etc with ease. 

```ts
import * as Portal from '@radix-ui/react-portal'
import type { ReactNode } from 'react'
import { AnimatePresence, AnimateSharedLayout } from 'framer-motion'

type Props = {
  children: ReactNode
}

export const NotificationList = ({ children }: Props) => {
  return (
    <Portal.Root>
      <AnimateSharedLayout>
        <ul
          aria-live="assertive"
          className="flex fixed z-50 flex-col gap-4 m-4 lg:m-8 pointer-events-none"
        >
          <AnimatePresence initial={false}>{children}</AnimatePresence>
        </ul>
      </AnimateSharedLayout>
    </Portal.Root>
  )
}

```
 

### Notification item component

The Notification item itself will be a component that renders the notification text, has an icon and a style based on its type, and also provides a way to close the notification and an optional callback to run when closing the notification. 

You could also implement the possibility for a custom action inside the notification etc, but I am keeping it simple for this demo. 


#### Notification item types

```ts
// src/features/notifications/components/NotificationItem.tsx
export type NotificationTypes = 'success' | 'error' | 'warning' | 'info'

export type Notification = {
  /**
   * The notification id.
   */
  id: string

  /**
   * The message of the notification
   */
  message: string

  /**
   * An optional dismiss duration time
   *
   * @default 6000
   */
  autoHideDuration?: number

  /**
   * The type of notification to show.
   */
  type?: NotificationTypes

  /**
   * Optional callback function to run side effects after the notification has closed.
   */
  onClose?: () => void

  /**
   * Optionally add an action to the notification through a ReactNode
   */
  action?: ReactNode
}

type Props = {
  notification: Notification
}
```

#### Notification item motion direction and position

This is only necessary to easily switch between different rendering positions for demo purposes. In a real world app, you would most likely choose a single position to render all notifications in. 

```ts
// src/features/notifications/components/NotificationItem.tsx
/**
 * To handle different positions of the notification, we need to change the
 * animation direction based on whether it is rendered in the top/bottom or left/right.
 *
 * @param position - The position of the Notification
 * @param fromEdge - The length of the position from the edge in pixels
 */
const getMotionDirectionAndPosition = (
  position: NotificationPositions,
  fromEdge = 24
) => {
  const directionPositions: NotificationPositions[] = ['top', 'bottom']
  const factorPositions: NotificationPositions[] = ['top-right', 'bottom-right']

  const direction = directionPositions.includes(position) ? 'y' : 'x'
  let factor = factorPositions.includes(position) ? 1 : -1

  if (position === 'bottom') factor = 1

  return {
    [direction]: factor * fromEdge,
  }
}

```

#### Notification item motion variants (Framer motion)

This is the [Framer Motion variants](https://www.framer.com/docs/animation/#variants) that will control how the notification item is animated on and off screen. 

```ts
// src/features/notifications/components/NotificationItem.tsx
const motionVariants: Variants = {
  initial: (position: NotificationPositions) => {
    return {
      opacity: 0,
      ...getMotionDirectionAndPosition(position),
    }
  },
  animate: {
    opacity: 1,
    y: 0,
    x: 0,
    scale: 1,
    transition: {
      duration: 0.4,
      ease: [0.4, 0, 0.2, 1],
    },
  },
  exit: (position) => {
    return {
      opacity: 0,
      ...getMotionDirectionAndPosition(position, 30),
      transition: {
        duration: 0.2,
        ease: [0.4, 0, 1, 1],
      },
    }
  },
}

```

#### Notification item component implementation

And lastly the notification item implementation itself. 


```ts
export const NotificationItem = ({
  notification: { id, autoHideDuration, message, onClose, type = 'info' },
}: Props) => {
  const dispatch = useAppDispatch()
  const duration = useNotificationDuration() // Demo purposes
  const isPresent = useIsPresent()
  const position = useNotificationPosition() // Demo purposes
  const prefersReducedMotion = usePrefersReducedMotion()

  // Handle dismiss of a single notification
  const handleDismiss = () => {
    if (isPresent) {
      dispatch(dismissNotification(id))
    }
  }

  // Call the dismiss function after a certain timeout
  const [, cancel, reset] = useTimeoutFn(
    handleDismiss,
    autoHideDuration ?? duration
  )

  // Reset or cancel dismiss timeout based on mouse interactions
  const onMouseEnter = () => cancel()
  const onMouseLeave = () => reset()

  // Call `onDismissComplete` when notification unmounts if present
  useUpdateEffect(() => {
    if (!isPresent) {
      onClose?.()
    }
  }, [isPresent])

  return (
    <motion.li
      className={clsx(
        'flex w-max items-center shadow px-4 py-3 rounded border transition-colors duration-100 min-w-[260px] text-sm pointer-events-auto',
        notificationStyleVariants[type]
      )}
      initial="initial"
      animate="animate"
      exit="exit"
      layout="position"
      custom={position}
      variants={!prefersReducedMotion ? motionVariants : {}}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <div className="flex gap-2 items-center">
        {notificationIcons[type]}
        <span className="max-w-sm font-medium">{message}</span>
      </div>

      <div className="pl-4 ml-auto">
        <button
          onClick={handleDismiss}
          className={clsx(
            'p-1 rounded transition-colors duration-100',
            closeButtonStyleVariants[type]
          )}
        >
          <Cross2Icon />
        </button>
      </div>
    </motion.li>
  )
}

```

Different parts of the component is styled by grabbing tailwind classes from an object based on type. 

#### Notification item component full file

```ts
import clsx from 'clsx'
import { ReactNode } from 'react'
import { motion, useIsPresent, type Variants } from 'framer-motion'
import { useTimeoutFn, useUpdateEffect } from 'react-use'

import {
  CheckCircledIcon,
  Cross2Icon,
  ExclamationTriangleIcon,
  InfoCircledIcon,
} from '@radix-ui/react-icons'

import {
  dismissNotification,
  NotificationPositions,
  useNotificationDuration,
  useNotificationPosition,
} from '@features/notifications/notification.slice'
import { useAppDispatch } from '@redux/hooks'
import { usePrefersReducedMotion } from '@app/core/hooks/usePrefersReducedMotion'

export type NotificationTypes = 'success' | 'error' | 'warning' | 'info'

export type Notification = {
  /**
   * The notification id.
   */
  id: string

  /**
   * The message of the notification
   */
  message: string

  /**
   * An optional dismiss duration time
   *
   * @default 6000
   */
  autoHideDuration?: number

  /**
   * The type of notification to show.
   */
  type?: NotificationTypes

  /**
   * Optional callback function to run side effects after the notification has closed.
   */
  onClose?: () => void

  /**
   * Optionally add an action to the notification through a ReactNode
   */
  action?: ReactNode
}

type Props = {
  notification: Notification
}

/**
 * To handle different positions of the notification, we need to change the
 * animation direction based on whether it is rendered in the top/bottom or left/right.
 *
 * @param position - The position of the Notification
 * @param fromEdge - The length of the position from the edge in pixels
 */
const getMotionDirectionAndPosition = (
  position: NotificationPositions,
  fromEdge = 24
) => {
  const directionPositions: NotificationPositions[] = ['top', 'bottom']
  const factorPositions: NotificationPositions[] = ['top-right', 'bottom-right']

  const direction = directionPositions.includes(position) ? 'y' : 'x'
  let factor = factorPositions.includes(position) ? 1 : -1

  if (position === 'bottom') factor = 1

  return {
    [direction]: factor * fromEdge,
  }
}

const motionVariants: Variants = {
  initial: (position: NotificationPositions) => {
    return {
      opacity: 0,
      ...getMotionDirectionAndPosition(position),
    }
  },
  animate: {
    opacity: 1,
    y: 0,
    x: 0,
    scale: 1,
    transition: {
      duration: 0.4,
      ease: [0.4, 0, 0.2, 1],
    },
  },
  exit: (position) => {
    return {
      opacity: 0,
      ...getMotionDirectionAndPosition(position, 30),
      transition: {
        duration: 0.2,
        ease: [0.4, 0, 1, 1],
      },
    }
  },
}

const notificationStyleVariants: Record<
  NonNullable<Notification['type']>,
  string
> = {
  success: 'bg-green-3 border-green-6',
  error: 'bg-red-3 border-red-6',
  info: 'bg-purple-3 border-purple-6',
  warning: 'bg-yellow-3 border-yellow-6',
}

const notificationIcons: Record<
  NonNullable<Notification['type']>,
  ReactNode
> = {
  success: <CheckCircledIcon />,
  error: <ExclamationTriangleIcon />,
  info: <InfoCircledIcon />,
  warning: <ExclamationTriangleIcon />,
}

const closeButtonStyleVariants: Record<
  NonNullable<Notification['type']>,
  string
> = {
  success: 'hover:bg-green-5 active:bg-green-6',
  error: 'hover:bg-red-5 active:bg-red-6',
  info: 'hover:bg-purple-5 active:bg-purple-6',
  warning: 'hover:bg-yellow-5 active:bg-yellow-6',
}

export const NotificationItem = ({
  notification: { id, autoHideDuration, message, onClose, type = 'info' },
}: Props) => {
  const dispatch = useAppDispatch()
  const duration = useNotificationDuration()
  const isPresent = useIsPresent()
  const position = useNotificationPosition()
  const prefersReducedMotion = usePrefersReducedMotion()

  // Handle dismiss of a single notification
  const handleDismiss = () => {
    if (isPresent) {
      dispatch(dismissNotification(id))
    }
  }

  // Call the dismiss function after a certain timeout
  const [, cancel, reset] = useTimeoutFn(
    handleDismiss,
    autoHideDuration ?? duration
  )

  // Reset or cancel dismiss timeout based on mouse interactions
  const onMouseEnter = () => cancel()
  const onMouseLeave = () => reset()

  // Call `onDismissComplete` when notification unmounts if present
  useUpdateEffect(() => {
    if (!isPresent) {
      onClose?.()
    }
  }, [isPresent])

  return (
    <motion.li
      className={clsx(
        'flex w-max items-center shadow px-4 py-3 rounded border transition-colors duration-100 min-w-[260px] text-sm pointer-events-auto',
        notificationStyleVariants[type]
      )}
      initial="initial"
      animate="animate"
      exit="exit"
      layout="position"
      custom={position}
      variants={!prefersReducedMotion ? motionVariants : {}}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <div className="flex gap-2 items-center">
        {notificationIcons[type]}
        <span className="max-w-sm font-medium">{message}</span>
      </div>

      <div className="pl-4 ml-auto">
        <button
          onClick={handleDismiss}
          className={clsx(
            'p-1 rounded transition-colors duration-100',
            closeButtonStyleVariants[type]
          )}
        >
          <Cross2Icon />
        </button>
      </div>
    </motion.li>
  )
}

```

Lastly output the `Notifications` component at a root level e.g. under Next.js `_app.tsx` wrapper 

```ts
import '@styles/globals.css'
import type { AppProps } from 'next/app'
import { Provider } from 'react-redux'

import { Notifications } from '@features/notifications/components/Notifications'
import { store } from '@redux/store'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <>
      <Provider store={store}>
        <Component {...pageProps} />

        <Notifications />
      </Provider>
    </>
  )
}

export default MyApp

```

It is now possible to dispatch the Redux action we created in the state slice; `addNotification` from any component in the app, and render a notification. 👍

```ts
// Any component

import { addNotification } from '@features/notifications/notification.slice'
import { useAppDispatch } from '@redux/hooks'

export const Component = () => {
  const dispatch = useAppDispatch()

  return (
    <button
      onClick={() =>
        dispatch(
          addNotification({
            message: 'Hello world!',
            type: 'info',
            onClose: () => console.log('I was closed'),
            autoHideDuration: 6000,
          })
        )
      }
    >
      Render notification
    </button>
  )
}

```

Demo: [Here](https://medium-notifications-demo.vercel.app/)
Github repository: [Here](https://github.com/christofferberg/medium-notifications-demo)


**Notes**

The notification animation has been inspired by/copied from https://chakra-ui.com/docs/feedback/toast