Article summary
I recently helped build a web app using React and TypeScript, which needed to load and display a lot of page-specific data from a REST API. One challenge on this project was figuring out a nice way to deal with loading data and making it available to any components on the page that needed access to it.
We eventually settled on a technique that makes use of a component that uses a React context and provides a hook to subscribe to the data being loaded. We called the resulting components “data providers,” and the technique worked extremely well.
Why Not Just Use a Hook?
We initially tried to write a hook, something like useApi
, that could be used directly in the component that needed to display the data loaded from the API.
export const ItemDisplay: React.FC<{ id: number }> = (props) => {
const data = useApi(() => ApiRequests.getItemData(props.id));
return <div>Hello {data.name}</div>;
};
useApi
would be responsible for holding on to the data that was retrieved, storing it in local state using useState
.
We ran into a few problems with this:
- When other components on the same page needed access to the results, we’d end up having to prop the fetched data down from component to component. Or if we used the same hook in multiple components on the same page, we’d end up making the same call multiple times.
- We were unable to write Storybook stories that were dependent on data loaded using the
useApi
hook (without having to do some kind of function mocking). - Components that might be mounted on unmounted on a page based on a user interaction would fetch data each time they were mounted, even though it was really only necessary to do it once per page load.
Use a Context Instead
To address those issues, we separated the data loading into a separate component that exposes a hook used to retrieve whatever data has been fetched by the component. It uses a React context, so multiple child components can access the same data, even when deeply nested, without having to prop the data down by hand.
Since the data loading and storage are decoupled from the components that are displaying it, it won’t be re-fetched when display components are mounted and unmounted. And it’s trivial to provide fake data to a child component for unit testing or Storybook stories.
An Example
Here’s a data provider called ItemDataProvider
that will fetch data about a single Item
, along with a ItemDisplay
component that will display the loaded data:
...
<ItemDataProvider itemId={3}>
<ItemDisplay />
</ItemDataProvider>
...
The Display Component
The ItemDisplay
component displays the data that’s loaded by the data provider:
export const ItemDisplay: React.FC = () => {
const data = useItemData();
if (data.status === 'LOADING') {
return <Spinner />;
} else if (data.status === 'ERROR') {
return <div>Unable to load item data</div>;
}
return <div>Hello {data.value.name}</div>;
};
There are a couple of things worth noting here:
- This component doesn’t know anything about where the data is coming from. Whatever
useItemData
returns is what will be displayed. - The shape of the returned data includes a
status
property that can beLOADING
,ERROR
, orLOADED
, allowing the component to decide how to handle data that’s not yet been retrieved or has failed to load.
The Data Provider
And now the implementation of the data provider:
type ContextState =
{ status: 'LOADING' | 'ERROR' }
| { status: 'LOADED'; value: { name: string } };
const Context = React.createContext<ContextState | null>(null);
export const useItemData = (): ContextState => {
const contextState = React.useContext(Context);
if (contextState === null) {
throw new Error('useItemData must be used within a ItemDataProvider tag');
}
return contextState;
};
export const ItemDataProvider: React.FC<{ itemId: number }> = (props) => {
const [state, setState] =
React.useState<ContextState>({ status: 'LOADING' });
React.useEffect(() => {
setState({ status: 'LOADING '});
(async (): Promise<void> => {
const result = await ApiRequests.loadItemData(props.itemId);
if (result.ok) {
setState({
status: 'LOADED',
value: result,
});
} else {
setState({ status: 'ERROR' });
}
})();
}, [props.itemId]);
return (
<Context.Provider value={state}>
{props.children}
</Context.Provider>
);
};
export const TestItemDataProvider: React.FC<{ value: ContextState }> = (props) => (
<Context.Provider value={props.value}>
{props.children}
</Context.Provider>
);
Of note in this implementation are:
- The actual React
context
is never exposed directly (see How to use React context effectively). - The data will be fetched from the API when the component mounts and only again if the
props.itemId
changes. -
The
TestItemDataProvider
component can be used in Storybook or unit tests to easily provide whatever data is required, without having to mock functions or API calls.
Conclusion
This is only a trivial example, but it’s a good start that could be extended to form the basis for how all API data retrieval is done in a React application.
oyyyy