DEV Community

Julian Garamendy
Julian Garamendy

Posted on • Updated on • Originally published at juliangaramendy.dev

Sharing Remote Data with React Context

In this series, instead of using a state-management library or proposing a one-size-fits-all solution, we start from the bare minimum and we build up our state management as we need it.


  • In the first article we described how we load and display data with hooks.
  • In the second article we learned how to change remote data with hooks.
  • In this third article we'll see how to share data between components with React Context, without using globals, singletons or resorting to state management libraries like MobX or Redux.
  • In the fourth article we'll see how to share data between components using SWR, which is probably what we should have done from the beginning.

The final code can be found in this GitHub repo. It's TypeScript, but the type annotations are minimal. Also, please note this is not production code. In order to focus on state management, many other aspects have not been considered (e.g. Dependency Inversion, testing or optimisations).

Sharing Remote Data with React Context

⚠️ Before we begin, you may want to check out this tag from the repo. The project has been improved from the last article with some styling and game screenshots.

Remember our list of games? There's a third requirement: We want to display a sign near the top of the page, indicating the total number of games, how many are finished and how many are in progress. Something like this:

List of games with totals panel

Lifting State to a common ancestor

But first let's imagine our application is getting a bit more complex and we decide to break it into separate components. We'll create a GamesScreen and a GameGrid component.

App
 +- GamesScreen (useGames hook here)
         +- Totals
         +- GameGrid
Enter fullscreen mode Exit fullscreen mode

Now our App component is not responsible for fetching the games list. We do that in GamesScreen.

export const App = () => {
  return (
    <>
      <h1>My Favourite Commodore 64 Games</h1>
      <GamesScreen />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

The new GamesScreen component uses our useGames custom hook to keep state and handle the error and pending states and eventually rendering two children components.

export const GamesScreen = () => {
  const { games, error, isPending, markAsFinished } = useGames();

  return (
    <>
      {error && <pre>ERROR! {error}...</pre>}
      {isPending && <pre>LOADING...</pre>}
      <Totals games={games} />
      <GameGrid games={games} markAsFinished={markAsFinished} />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

We extracted some code to a GameGrid compoent.

type GameGridProps = { games: Game[]; markAsFinished: (id: number) => void };

export const GameGrid = ({ games, markAsFinished }: GameGridProps) => {
  return (
    <div className="gamegrid">
      {games.map(game => (
        <GameComponent key={game.id} game={game} markAsFinished={markAsFinished} />
      ))}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

And finally we can create a new Totals component:

type TotalsProps = { games: Game[] };

export const Totals = ({ games }: TotalsProps) => {
  const totalGames = games.length;
  const inProgress = games.filter(g => g.status === 'in-progress').length;
  const finished = games.filter(g => g.status === 'finished').length;

  return (
    <div className="card">
      total games: {totalGames}<br />
      in progress️: {inProgress}<br />
      finished: {finished}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The resulting code can be found in the repo under the 08-lifting-state tag.

Sharing State using hooks (the wrong way)

That's all we need to know if two sibling components need access to the same state.

But what if it we have a more complex component tree?

If the components sharing state are far apart, getting the required props to each of them may result in prop drilling. Let's imagine an even more complex structure:

App
 +- GamesScreen (useGames hook here)
     +- MenuBar❗
         +- SomeOtherMenuComponent ❗
             +- Totals (requires the list of games)
     +- GamesPageContent❗
         +- SomeOtherComponent❗
             +- GameGrid (requires the list of games and the markAsFinished function)
Enter fullscreen mode Exit fullscreen mode

With the above structure we would need to keep the state in GamesScreen because it's the closest common ancestor of GameGrid and Totals.

The problem is that in order to pass the required props, MenuBar, SomeOtherMenuComponent, GamesPageContent and SomeOtherComponent would require props with the list of games and the markAsFinished function, only to pass it down to some children component.

We don't want to do that. We can use React Context to solve this problem.

Note: To keep the demo repository and this article simple we won't create any of those intermediate components marked with ❗️.

We're going to pretend that the GameGrid and Total components are far apart.

Our current GamesScreen.tsx

export const GamesScreen = () => {
  const { games, error, isPending, markAsFinished } = useGames(); 

  return (
    <>
      {error && <pre>ERROR! {error}...</pre>}{isPending && <pre>LOADING...</pre>}<Totals games={games} />
      <GameGrid games={games} markAsFinished={markAsFinished} />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

If Totals and GameGrid are far apart they don't share a common parent (only a common ancestor higher up in the tree). That means we can't call the useGames hook here and pass some props down without resorting to prop-drilling, as explained above.

For now we're going to call useGames inside each of our components:

Updated GamesScreen.tsx

export const GamesScreen = () => {
  return (
    <>
      <Totals />
      <GameGrid />
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Updated GameGrid.tsx

export const GameGrid = () => {
  const { games, error, isPending, markAsFinished } = useGames();

  return (
    <div className="gamegrid">
      {error && <pre>ERROR! {error}...</pre>}
      {isPending && <pre>LOADING...</pre>}
      {games.map(game => (
        <GameComponent key={game.id} game={game} markAsFinished={markAsFinished} />
      ))}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The updated GameGrid component does not receive any props, but now it has to handle the error and pending states itself.

Updated Totals.tsx

export const Totals = () => {
  const { games } = useGames();

  const totalGames = games.length;
  const inProgress = games.filter(g => g.status === 'in-progress').length;
  const finished = games.filter(g => g.status === 'finished').length;

  return (
    <div className="card">
      total games: {totalGames}
      <br />
      in progress️: {inProgress}
      <br />
      finished: {finished}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

In the Totals component we only use {games} from the custom hook, because we don't need markAsFinished function and we don't worry about error and pending states for this small component.

You can inspect the code from the repo using the 09-duplicating-state tag.

Wait wasn't this about React Context?

The above code works because both components now access the same server API and request the same list of games. Twice. However, when we mark some games as finished, only the GameGrid component reflects this. The Totals component is not updated.

the total panel reports zero finished games but the gamegrid shows 2 games are finished

For example, after marking two games as finished, the GameGrid component shows them as finished, as expected, but the Totals component continues to report zero finished games.

This is why we need to fetch and update only one list of games.

Sharing state using React Context (the right way)

OK. Let's see how we do this with React Context.

We're going to update our GamesScreen component.

export const GamesScreen = () => {
  return (
    <GamesContextProvider>
      <Totals />
      <GameGrid />
    </GamesContextProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Instead of wrapping Totals and GameGrid in a fragment <>, we wrap them in a new GamesContextProvider component which we'll create next.

GamesContext.tsx

type GamesContext = ReturnType<typeof useGames>;

export const gamesContext = React.createContext<GamesContext>({
  games: [],
  error: null,
  isPending: true,
  markAsFinished: () => {}
});

export const GamesContextProvider: React.FC = ({ children }) => {
  return <gamesContext.Provider value={useGames()}>{children}</gamesContext.Provider>;
};
Enter fullscreen mode Exit fullscreen mode

For more information see Using React Context and React Hooks Reference: useContext.

This is the simplest thing we could do. Then we update our GameGrid and Totals components to use the context like this:

import { gamesContext } from '../GamesContext';

export const GameGrid = () => {
//const { games, error, isPending, markAsFinished } = useGames();
  const { games, error, isPending, markAsFinished } = React.useContext(gamesContext);
Enter fullscreen mode Exit fullscreen mode

But there's a problem. If we forget to wrap this component in GamesContextProvider or if someone in the future accidentally removes it, there won't be any errors. The list of games will never be loaded, and the context will never change its value.

You can try it. Check out the 10-minimal-context tag and edit GamesScreen.tsx removing the context provider to see that the games never load.

A better approach is to use undefined as a default value for our context.

type GamesContext = ReturnType<typeof useGames>;

const gamesContext = React.createContext<GamesContext | undefined>(undefined);

export const useGamesContext = (): GamesContext => {
  const context = React.useContext(gamesContext);
  if (!context) {
    throw new Error(
      'useGameContext must be used in a component within a GameContextProvider.'
    );
  }
  return context;
};
Enter fullscreen mode Exit fullscreen mode

We also create a custom useGamesContext hook that throws if the context is undefined, which can only happen if the provider is missing.

import { useGamesContext } from '../GamesContext';

export const GameGrid = () => {
//const { games, error, isPending, markAsFinished } = React.useContext(gamesContext);
  const { games, error, isPending, markAsFinished } = useGamesContext();

Enter fullscreen mode Exit fullscreen mode

We do the same in the Totals component.

import { useGamesContext } from '../GamesContext';

export const Totals = () => {
//const { games } = React.useContext(gamesContext);
  const { games } = useGamesContext();

Enter fullscreen mode Exit fullscreen mode

That's it! The final version of code can be found in the 11-safe-context tag.

correct-totals

Conclusion

We have managed to share state in our aplication without making it global and without suffering from prop drilling. Any component requiring access to the games list can use the custom hook provided. This hooks exposes a function to mutate such data in a safe way, while immediately persisting it on the server according to our business logic.

Resources

Further reading:

Top comments (0)