Redux Powered Notification Pipeline Pt. 1: Alerts
Table of Contents
Timely and relevant feedback from application events is critical to maintaining user engagement. Two standard ways of
delivering immediate event feedback are through the use of alerts and toast messages. To avoid a lot of
boilerplate markup popping up all over the project I wanted to make it as simple as just dispatching an action such as
“dispatch(errorAlert('your call cannot be completed as dialed');
” and have the alert appear on the screen. I am using
Bootstrap for this project but the same concept should translate to other frameworks such as Ant Design,
Material UI, or Foundation for Sites.
What follows is a short replay of how I implemented an asynchronous alerts pipeline in my Redux template project , and one or more of the issues, and their subsequent solutions, I ran into during the process.
Implementation#
Bootstrap was added to the project via a global stylesheet:
file: /src/styles/global.scss
@import '~bootstrap/scss/bootstrap';
and then included along with their JavaScript plugin in the project’s startup file:
file: /src/index.tsx
import React from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { Provider } from 'react-redux';
import 'bootstrap';
import './styles/global.scss';
import store from './store';
import routes from './routes';
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<RouterProvider router={routes} />
</Provider>
</React.StrictMode>
);
As we can see from their documentation there are several different styles of alert available from their framework
that are powered by the addition of a custom class such as alert-success
or alert-info
.
Typing#
For this exercise I am only concerned with a specific subset of these, so I created an enumeration with an associated type
that allows me to utilize their string-based names in a more type safe manner. This has an additional benefit of protecting
me from any inevitable typos (i.e. btn-sucess
btn-daanger
etc.).
file: /src/@enums/AlertTypes.ts
enum AlertTypes {
Error = 'danger',
Info = 'info',
Success = 'success',
Warning = 'warning',
}
export default AlertTypes;
file: /src/@types/AlertType.ts
import AlertTypes from '../@enums/AlertTypes';
type AlertType = AlertTypes.Error
| AlertTypes.Info
| AlertTypes.Success
| AlertTypes.Warning;
export default AlertType;
Before we can begin to flesh out a slice for our alerts we need to decide the shape of the alert object that we will be placing in the store. The Bootstrap documentation shows some good examples of the kind of content you can put inside of a Bootstrap Alert, so for this first pass I am just going with the alert text, type, and an optional title:
file: /src/@interfaces/IAlert.ts
import AlertType from '../@types/AlertType';
interface IAlert {
type: AlertType;
text: string;
title?: string;
}
export default IAlert;
Slice#
For the slice I am thinking that we just need to hold and array of our IAlert
shaped objects that can be picked up by a reactive
component watching for changes to the store.alerts
. Since we are actually adding to an array of alert messages I
believe a more accurate action name should be dispatch(addErrorAlert(...
as opposed to my initial thought of
dispatch(errorAlert(...
to better reflect how we are actually changing the store. Let’s start off with just two
actions in order to assess the validity of this approach. This felt like a good first pass:
file: /src/store/slices/alerts.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import IAlert from '../../@interfaces/IAlert';
import AlertTypes from '../../@enums/AlertTypes';
import { IStore } from '../';
const initialState: Array<IAlert> = [];
export const alerts = createSlice({
name: 'alerts',
initialState,
reducers: {
addInfoAlert: (state: Array<IAlert>, action: PayloadAction<IAlert>) => {
state.push({
...action.payload,
type: AlertTypes.Info,
});
},
addSuccessAlert: (state: Array<IAlert>, action: PayloadAction<IAlert>) => {
state.push({ ...action.payload, type: AlertTypes.Success });
},
},
});
export const { addInfoAlert, addSuccessAlert } = alerts.actions;
export const selectAlerts = (state: IStore) => state.alerts;
export default alerts.reducer;
Container and Alert Components#
Now we just need a functional container component to watch our store for new alerts:
file: /src/components/app/AppAlerts/AppAlerts.tsx
import React, { FC } from 'react';
import { useAppSelector } from '../../../helpers';
import { selectAlerts } from '../../../store/slices/alerts';
import styles from './AppAlerts.module.scss';
import Alert from './Alert';
import IAlert from '../../../@interfaces/IAlert';
const AppAlerts: FC = () => {
const alerts = useAppSelector(selectAlerts);
return (
<div className={styles.wrapper}>
<div className={styles.alertsCol}>
{alerts.map((alert: IAlert, index: number) => (
// yes `index` is a bit greasy, but give me a minute
<Alert key={index} alert={alert} />
))}
</div>
</div>
);
};
export default AppAlerts;
file: /src/components/app/AppAlerts/Alert/Alert.tsx
import React, { FC } from 'react';
import IAlert from '../../../../@interfaces/IAlert';
export interface IAlertProps {
alert: IAlert;
}
const Alert: FC<IAlertProps> = ({ alert }) => {
return (
<div
className={`alert alert-${alert.type} alert-dismissible fade show d-flex align-items-center`}
role="alert">
<div>
{alert.title ? <h5 className="mb-0">{alert.title}</h5> : null}
{alert.text}
</div>
<button
type="button"
className="btn-close"
data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
);
};
export default Alert;
Since we want this to be available from anywhere in the application I am going to put it next to our main RouteProvider.
file: /src/index.tsx
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { Provider } from 'react-redux';
import 'bootstrap';
import AppAlerts from './components/app/AppAlerts';
import './styles/global.scss';
import store from './store';
import routes from './routes';
createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<AppAlerts />
<RouterProvider router={routes} />
</Provider>
</React.StrictMode>,
);
Test Page#
And now an ugly little screen to test our logic:
file: /src/pages/NotificationsDemo/NotificationsDemo.tsx
import React from 'react';
import { useAppDispatch } from '../../helpers';
import { addInfoAlert, addSuccessAlert } from '../../store/slices/alerts';
const NotificationsDemo = () => {
const dispatch = useAppDispatch();
const handleInfoAlert = () =>
dispatch(addInfoAlert({ title: 'Optional Title', text: 'This is purely informational' }));
const handleSuccessAlert = () =>
dispatch(addSuccessAlert({ text: 'very win, highly success' }));
return (
<div className="row">
<div className="col-12">
<button onClick={handleInfoAlert} className="btn btn-info me-3">
Info
</button>
<button onClick={handleSuccessAlert} className="btn btn-success me-3">
Success
</button>
</div>
</div>
);
};
export default NotificationsDemo;
…and, our first error#
The excellent TypeScript linter has alerted me to a problem. The way that I have designed the actions they require the entire IAlert
interface including the type. I was really hoping to abstract that implementation detail away from the developer so let’s
see what can be done about that.
The solution was actually fairly easy. Redux Toolkit provides an optional prepare callback argument to allow
you to do additional processing to the incoming action’s payload before passing it on to the actual reducer. Here I
configure the action prepare
method to accept a partial IAlert
which we then fill in the missing AlertType
field before passing the now complete IAlert
to the reducer.
file: /src/store/slices/alerts.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import IAlert from '../../@interfaces/IAlert';
import AlertTypes from '../../@enums/AlertTypes';
import { IStore } from '../';
const initialState: Array<IAlert> = [];
export const alerts = createSlice({
name: 'alerts',
initialState,
reducers: {
addInfoAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: { ...alert, type: AlertTypes.Info } };
},
},
addSuccessAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: { ...alert, type: AlertTypes.Success } };
},
},
},
});
export const { addInfoAlert, addSuccessAlert } = alerts.actions;
export const selectAlerts = (state: IStore) => state.alerts;
export default alerts.reducer;
First Test Drive#
ESLint is happy and we can now fire up the app and see what damage we have done.
Looks like the components are accurately reflecting the current store:
Most of us have experienced the feeling, right?
…oh, a second design flaw#
Let’s click the close buttons to get rid of these before we take a victory lap - but what’s this? The state still contains our alerts that we closed.
The really bad thing is that the alerts functionality still works and will add new alerts and not display the old ones, but that still leaves state that is no longer relevant in our store. Of course this is not optimal so we need to fix it.
Removing Stale Messages#
We need to add a unique identifier for each alert so that we can easily locate it and remove it from state via a new action. Let’s add uuid to help with this:
yarn add uuid
yarn add --dev @types/uuid
# or if you prefer
npm i uuid
npm i -D @types/uuid
Add an id
property to our alert interface:
file: /src/@interfaces/IAlert.ts
import AlertType from '../@types/AlertType';
interface IAlert {
id: string;
type: AlertType;
text: string;
title?: string;
}
export default IAlert;
We will generate the id and add it to the alert in the action’s prepare
callback. Since this is starting to add some
repetitive logic let us go ahead and pull the payload preparation logic out to a helper method.
file: /src/store/slices/alerts.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { v4 as uuid } from 'uuid';
import AlertTypes from '../../@enums/AlertTypes';
import AlertType from '../../@types/AlertType';
import IAlert from '../../@interfaces/IAlert';
import { IStore } from '../';
const initialState: Array<IAlert> = [];
const createAlertPayload = (type: AlertType, alert: Partial<IAlert>): IAlert => {
return {
...alert,
id: uuid(),
type,
};
};
export const alerts = createSlice({
name: 'alerts',
initialState,
reducers: {
addInfoAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: createAlertPayload(AlertTypes.Info, alert) };
},
},
addSuccessAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: createAlertPayload(AlertTypes.Success, alert) };
},
},
},
});
export const { addInfoAlert, addSuccessAlert } = alerts.actions;
export const selectAlerts = (state: IStore) => state.alerts;
export default alerts.reducer;
Back in the slice let’s add an action to remove a specified alert from the array.
file: /src/store/slices/alerts.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { v4 as uuid } from 'uuid';
import AlertTypes from '../../@enums/AlertTypes';
import AlertType from '../../@types/AlertType';
import IAlert from '../../@interfaces/IAlert';
import { IStore } from '../';
const initialState: Array<IAlert> = [];
const createAlertPayload = (type: AlertType, alert: Partial<IAlert>): IAlert => {
return {
...alert,
id: uuid(),
type,
};
};
export const alerts = createSlice({
name: 'alerts',
initialState,
reducers: {
removeAlert: (state: Array<IAlert>, action: PayloadAction<string>) => {
const index = state.findIndex(a => a.id === action.payload);
if (index > -1) {
state.splice(index, 1);
}
},
addInfoAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: createAlertPayload(AlertTypes.Info, alert) };
},
},
addSuccessAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: createAlertPayload(AlertTypes.Success, alert) };
},
},
},
});
// don't forget to export the new action
export const { addInfoAlert, addSuccessAlert, removeAlert } = alerts.actions;
export const selectAlerts = (state: IStore) => state.alerts;
export default alerts.reducer;
Now we can fix that greasy key in the alerts container:
file: /src/components/app/AppAlerts/AppAlerts.tsx
import React, { FC } from 'react';
import { useAppSelector } from '../../../helpers';
import { selectAlerts } from '../../../store/slices/alerts';
import styles from './AppAlerts.module.scss';
import Alert from './Alert';
import IAlert from '../../../@interfaces/IAlert';
const AppAlerts: FC = () => {
const alerts = useAppSelector(selectAlerts);
return (
<div className={styles.wrapper}>
<div className={styles.alertsCol}>
{alerts.map((alert: IAlert, index: number) => (
<Alert key={alert.id} alert={alert} />
))}
</div>
</div>
);
};
export default AppAlerts;
So now we know each alert will have its own unique identifier, and that we have an action to remove an alert object out
of the state array using the identifier - the question arises where is the best place to actually dispatch the removeAlert
action from? Back in the Bootstrap Alert documentation it shows a pair of events, close.bs.alert
and closed.bs.alert
that we could attach an event listener to. Initially I chose closed.bs.alert
thinking that the component might unexpectedly
disappear from the user’s screen while animations were still in progress. After running into intermittent errors I realized
that I was causing errors while attempting to unbind an event listener from a div that Bootstrap had already destroyed.
By switching the event listener to close.bs.alert
it appears to give us enough time to unbind our listener without
throwing DOMNode errors. To attach our listener to this exact alert instance we will need a reference to the div
just
created which we can obtain from the useRef hook. We can be certain that the ref.current
is populated with the
desired DOM reference by wrapping it inside a single-fire useEffect.
file: /src/components/app/AppAlerts/Alert/Alert.tsx
import React, { FC, useEffect, useRef } from 'react';
import IAlert from '../../../../@interfaces/IAlert';
import { removeAlert } from '../../../../store/slices/alerts';
import { useAppDispatch } from '../../../../helpers';
export interface IAlertProps {
alert: IAlert;
}
const Alert: FC<IAlertProps> = ({ alert }) => {
const ref = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
useEffect(() => {
// do not inline this so that we have a reference to use for subscribing and unsubscribing
const handleClose = () => {
dispatch(removeAlert(alert.id));
};
// a reference to `this` exact Alert instance
const el = ref.current;
el!.addEventListener('close.bs.alert', handleClose);
// to ensure that we do not leave a zombie process hanging around in memory
// this method will fire when the component unmounts
return () => {
el!.removeEventListener('close.bs.alert', handleClose);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
ref={ref}
className={`alert alert-${alert.type} alert-dismissible fade show d-flex align-items-center`}
role="alert">
<div>
{alert.title ? <h5 className="mb-0">{alert.title}</h5> : null}
{alert.text}
</div>
<button
type="button"
className="btn-close"
data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
);
};
export default Alert;
Second Test Drive#
This should be all of the moving pieces required to keep our state tidy. Clicking both buttons so we have a duplicate scenario to the first time through:
Let’s close the first info alert and check the state:
Finishing Touches#
Now that we know everything works as intended we can implement the remaining actions missing from the alerts slice and call it day:
file: /src/store/slices/alerts.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { v4 as uuid } from 'uuid';
import AlertTypes from '../../@enums/AlertTypes';
import AlertType from '../../@types/AlertType';
import IAlert from '../../@interfaces/IAlert';
import { IStore } from '../';
const initialState: Array<IAlert> = [];
const createAlertPayload = (type: AlertType, alert: Partial<IAlert>): IAlert => {
return {
...alert,
id: uuid(),
type,
};
};
export const alerts = createSlice({
name: 'alerts',
initialState,
reducers: {
removeAlert: (state: Array<IAlert>, action: PayloadAction<string>) => {
const index = state.findIndex(a => a.id === action.payload);
if (index > -1) {
state.splice(index, 1);
}
},
addErrorAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: createAlertPayload(AlertTypes.Error, alert) };
},
},
addInfoAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: createAlertPayload(AlertTypes.Info, alert) };
},
},
addSuccessAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: createAlertPayload(AlertTypes.Success, alert) };
},
},
addWarningAlert: {
reducer(state: Array<IAlert>, action: PayloadAction<IAlert>) {
state.push(action.payload);
},
prepare(alert: Partial<IAlert>): any {
return { payload: createAlertPayload(AlertTypes.Warning, alert) };
},
},
},
});
export const { addErrorAlert, addInfoAlert, addSuccessAlert, addWarningAlert, removeAlert } =
alerts.actions;
export const selectAlerts = (state: IStore) => state.alerts;
export default alerts.reducer;
Bonus Round#
While our alerts look nice why don’t we go the extra few feet and add some FontAwesome icons for a little flair. Here I am just taking what was shown in the docs and swapping in FontAwesome’s icons:
file: /src/components/app/AppAlerts/Alert/Alert.tsx
import React, { FC, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconDefinition } from '@fortawesome/free-regular-svg-icons';
import {
faCircleCheck,
faCircleInfo,
faCircleQuestion,
faCircleXmark,
faTriangleExclamation,
} from '@fortawesome/free-solid-svg-icons';
import IAlert from '../../../../@interfaces/IAlert';
import { removeAlert } from '../../../../store/slices/alerts';
import { useAppDispatch } from '../../../../helpers';
import AlertTypes from '../../../../@enums/AlertTypes';
export interface IAlertProps {
alert: IAlert;
}
const Alert: FC<IAlertProps> = ({ alert }) => {
const ref = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
useEffect(() => {
const handleClose = () => {
dispatch(removeAlert(alert.id));
};
const el = ref.current;
el!.addEventListener('close.bs.alert', handleClose);
return () => {
el!.removeEventListener('close.bs.alert', handleClose);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
let icon: IconDefinition;
switch (alert.type) {
case AlertTypes.Error:
icon = faCircleXmark;
break;
case AlertTypes.Success:
icon = faCircleCheck;
break;
case AlertTypes.Warning:
icon = faTriangleExclamation;
break;
case AlertTypes.Info:
icon = faCircleInfo;
break;
default:
icon = faCircleQuestion;
}
return (
<div
ref={ref}
data-testid={`alert-${alert.id}`}
className={`alert alert-${alert.type} alert-dismissible fade show d-flex align-items-center`}
role="alert">
<FontAwesomeIcon className="flex-shrink-0 me-2" icon={icon} />
<div>
{alert.title ? <h5 className="mb-0">{alert.title}</h5> : null}
{alert.text}
</div>
<button
type="button"
className="btn-close"
data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
);
};
export default Alert;
I feel the icon adds a little extra polish for some miniscule extra effort:
Conclusion#
I am going to wrap up this session by writing some unit tests around the new code which you will be able to see in the repository. I promise that part two, where we implement the toast’s pipeline, will be a lot shorter of an article. The process was nearly identical to developing alerts with a couple of minor twists which I will call out. As always feel free to drop me a line if you see anywhere that could use some improvement.