January 13, 2020
Handling global notifications with React's Context API
In this article, we aim to present a use case on how to use React Hooks and Context API in order to show notifications globally. The example presented here creates a way to show error messages (namely API error messages) reported by feature components (our pages) when a request is performed. The approach presented here was inspired by the pattern extinction level event. In the end, we also explain how to easily test some of the components created. You can find the full code examples described in this blogpost in this codesandbox.
Folder structure
We have a folder structure similar to the following:
/ src
--- / common
------ / api
------ / hooks
------ / providers
------ / routing
------ / test-utils
------ ...
------ App.js
--- / components
--- / features
--- / ...
package.json
src/App.js
is the entry point for our React application, similar to what is created with Create React App.
The src/components
folder contains basic react components that range from Button to Modal, Header, Footer, etc (for such a simple project we didn't feel the need to split using something such as atomic design). The features
folder contains the pages, that are smart components that handle network requests and the structure of the pages.
API requests, custom React Hooks and Providers created with Context API can be found in a common
folder, as they can be shared through the components (namely, features).
OK, but what do we want exactly to do? a bottom-up approach to push errors and make them visible. Context seemed a good candidate to handle that.
Setting up the context
Since we just need to show one error message at a time we can just save that message
(string) and the status
code in context. The functions to add and remove (clear) the error are also exposed in context through the provider.
// src/common/providers/APIErrorProvider/index.js
import React, { useState, useCallback } from 'react'
export const APIErrorContext = React.createContext({
error: null,
addError: () => {},
removeError: () => {},
})
export default function APIErrorProvider({ children }) {
const [error, setError] = useState(null)
const removeError = () => setError(null)
const addError = (message, status) => setError({ message, status })
const contextValue = {
error,
addError: useCallback((message, status) => addError(message, status), []),
removeError: useCallback(() => removeError(), []),
}
return (
<APIErrorContext.Provider value={contextValue}>
{children}
</APIErrorContext.Provider>
)
}
Adding the Provider to the application entry point
We need to add our APIErrorProvider
pretty much we add other providers such as Authentication provider or Material ui to our main App component.
// src/App.js
import React from 'react'
import APIErrorProvider from './common/providers/APIErrorProvider'
import APIErrorNotification from './components/APIErrorNotification'
function App() {
return (
<AuthProvider>
<APIErrorProvider>
{/* routing, feature components, etc */}
<APIErrorNotification />
</APIErrorProvider>
</AuthProvider>
)
}
export default App
Note that we also add
APIErrorNotification
component here which is the component that will render our popup notification with the error.
Using your own hook and avoid having to do useContext
in every component
The useAPIError
custom hook does not do anything fancy but makes the code more readable and avoids having to write too many lines. It exposes the addError
and removeError
functions and the error
to used by APIErrorNotification
.
// src/common/hooks/useAPIError/index.js
import { useContext } from 'react'
import { APIErrorContext } from '../../providers/APIErrorProvider'
function useAPIError() {
const { error, addError, removeError } = useContext(APIErrorContext)
return { error, addError, removeError }
}
export default useAPIError
Use the hook in a page
Now we can use our custom hook in a feature page, such as the following example. Here we are doing a request to an API when the component mounts (useEffect
with no dependencies). If the request fails we call the addError
method in our Provider and the context gets updated.
// src/features/HomePage/index.js
import useAPIError from '../../common/hooks/useAPIError'
import api from '../../common/api'
function HomePage() {
const { addError } = useAPIError()
useEffect(() => {
const fetchData = async () => {
try {
const data = await api.getData()
// do something useful with the data
} catch (err) {
addError(`LOAD_DATA_ERROR: ${err}`, err.response.status)
}
}
fetchData()
}, [addError])
return <div>Will render some data</div>
}
Render the error globally
Once the context gets updated, the APIErrorNotification
is notified that its value changed and rerenders. We can show a message in a paragraph (or markup, why not?), use a modal, a toast / snackbar or other. In this case, imagine that we have a modal component that just shows up when there's an error:
// src/components/APIErrorNotification/index.js
import useAPIError from '../../common/hooks/useAPIError'
import Modal from '../../components/Modal'
function APIErrorNotification() {
const { error, removeError } = useAPIError()
const handleSubmit = () => {
removeError()
}
return (
<Modal open={!!error} data-testid="notification-modal">
<div>
{error && error.message && <p>({error.message})</p>}
<button data-testid="notification-submit-button" onClick={handleSubmit}>
Ok
</button>
</div>
</Modal>
)
}
Note that the way to remove the error message is to submits the modal, which will update the context and trigger a rerender where modal will not show up.
Testing
We are going to use @testing-library/react in the following examples. It is really simple to use, and the whole idea is to test as a user which helps to identify the use cases and meet user acceptance criteria. There is also react-hooks-testing-library which might help to test our custom hooks.
// src/common/test-utils/render-component.js
import React from 'react'
import { render } from '@testing-library/react'
import { AuthContext } from '../providers/AuthProvider'
import { APIErrorContext } from '../providers/APIErrorProvider'
// Mocks
const errorMock = null
export default function renderComponent(
children,
{
// ... other props
error = errorMock,
} = {}
) {
const addErrorSpy = jest.fn()
const removeErrorSpy = jest.fn()
return {
...render(
// add other providers such as AuthContext
<APIErrorContext.Provider
value={{
error,
addError: addErrorSpy,
removeError: removeErrorSpy,
}}
>
{children}
</APIErrorContext.Provider>
),
addErrorSpy,
removeErrorSpy,
}
}
From this example you can see that:
- We can pass a custom error message or just use a mocked one;
- We are extending the functions provided by
@testing-library/react
(getByTestId
,queryByTestId
, etc) which means that we testaddErrorSpy
andremoveErrorSpy
usages properly.
In order to use it on a test we can do the following:
const { addErrorSpy, removeErrorSpy } = renderComponent(
<ComponentThatUsesContext />,
{
error: {
message: 'SOME_ERROR',
},
}
)
For example, if we add the data-testid
attribute to the submit button on the APIErrorNotification
component we can trigger it and confirm that removeErrorSpy
was called when the button was pressed.
// src/components/APIErrorNotification/APIErrorNotification.test.js
import React from 'react'
import { fireEvent, wait } from '@testing-library/react'
import renderComponent from '../../common/test-utils/render-component'
import APIErrorNotification from './'
it('should remove error when handleSubmit is pressed', async () => {
const ERROR_MESSAGE = 'SOME_ERROR'
const { getByTestId, removeErrorSpy } = renderComponent(
<APIErrorNotification />,
{
error: {
message: ERROR_MESSAGE,
},
}
)
wait(() => {
const submitModalBtn = getByTestId('notification-submit-button')
fireEvent.click(submitModalBtn)
expect(removeErrorSpy).toHaveBeenCalled()
})
})
Note that we wanted to show how it is possible to test the functions of the provider. However, in this particular example we could just have tested that the modal was not visible anymore, e.g.
// src/components/APIErrorNotification/APIErrorNotification.test.js
it('should not show modal when handleSubmit is pressed', async () => {
const ERROR_MESSAGE = 'SOME_ERROR'
const { getByTestId, queryByTestId } = renderComponent(
<APIErrorNotification />,
{
error: {
message: ERROR_MESSAGE,
},
}
)
wait(() => {
const submitModalBtn = getByTestId('notification-submit-button')
fireEvent.click(submitModalBtn)
const modal = queryByTestId('notification-modal')
expect(modal).toBeNull()
})
})
We hope this gave some ideas on how to use Context API and on how to test your use cases. Happy coding!
Originally published at blog.yld.io on January 13, 2020 by Daniela Matos de Carvalho (@sericaia on Twitter/Github)