DEV Community

Julian Garamendy
Julian Garamendy

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

Changing Remote Data with Hooks

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 this second article we'll learn how to change remote data with hooks.
  • In the 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).

Changing Remote Data with Hooks

We have our list of games from the previous article. Now there's a new requirement: We want to let the user mark each game as "finished". When they do, we send the changes to the server right away.

my favourite commodore 64 games

In order to change the value of "status" from "in-progress" to "finished" we make a PATCH request:

const setGameStatus = (id: number, status: Game['status']): Promise<Game> => {
  return fetch('http://localhost:3001/games/' + id, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ status: status })
    })
    .then(response => response.json());
}
Enter fullscreen mode Exit fullscreen mode

Which we can use like this:

const promise = setGameStatus(7, "finished");
Enter fullscreen mode Exit fullscreen mode

But, where do we put this code?

We can approach the problem from the other end: How would we like to use our hook?

Let's create a GameComponent to render a single game with an onClick handler to mark it as finished.


Note: To keep this simple we'll make a quick <pre> with everything in it, including the onClick handler.


type GameComponentProps = { game: Game; markAsFinished: (id:number) => void };

const GameComponent = ({ game, markAsFinished }: GameComponentProps) => {
  return (
    <pre onClick={() => markAsFinished(game.id)}>
      Title: {game.title}
      Year: {game.year}
      Status: {game.status}
    </pre>
  );
};
Enter fullscreen mode Exit fullscreen mode

This new component needs a game object and a markAsFinished function. So our custom hook should return a function along with the list of games, error and pending:

//const { games, error, isPending } = useGames();
  const { games, error, isPending, markAsFinished } = useGames();
Enter fullscreen mode Exit fullscreen mode

This would allow us to render the list of games like this:

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

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

That's what we would like to use. Let's change our useGames hook implementation.

Here's what it looks like so far:

export const useGames = () => {
  const [games, error, isPending] = useAsyncFunction(getGames, emptyList);
  return { games, error, isPending };
};
Enter fullscreen mode Exit fullscreen mode

We need to return a markAsFinished function.

export const useGames = () => {
  const [games, error, isPending] = useAsyncFunction(getGames, emptyList);

  const markAsFinished = (id: number) => {
    setGameStatus(id, 'finished'); // setGameStatus is already defined outside the hook
  };

  return { games, error, isPending, markAsFinished };
};
Enter fullscreen mode Exit fullscreen mode

With this code (see repo) we are now sending our changes to the server, but unless we reload the page and fetch the list of games again, our client-side data is not affected.

Updating client-side data

Now the server has the updated value, but the client does not: The list is not updated after a change.

const markAsFinished = (id: number) => {
  setGameStatus(id, 'finished')
    .then(game => ?????); // 🤔
};
Enter fullscreen mode Exit fullscreen mode

Our server's PATCH request returns a promise with the modified game object which we can use to update our client-side list. There's no need to re-fetch the list of games or even the affected game after "patching".

const markAsFinished = (id: number) => {
  setGameStatus(id, 'finished')
    .then(updateGame); // 🤔 we need to define updateGame
};
Enter fullscreen mode Exit fullscreen mode

Our updateGame function will make a copy of the array of games, find the game by id and replace it with the new one.

export const useGames = () => {
  const [games, error, isPending] = useAsyncFunction(getGames, emptyList);

  const updateGame = (game: Game) => {
    const index = games.findIndex(g => g.id === game.id);
    if (index >= 0) {
      const gamesCopy = games.slice();
      gamesCopy[index] = game;
      setGames(gamesCopy); // 🤔 I don't see setGames declared anywhere...
    }
  }
  const markAsFinished = (id: number) => {
    setGameStatus(id, 'finished').then(updateGame);
  };

  return { games, error, isPending, markAsFinished };
};
Enter fullscreen mode Exit fullscreen mode

Oh! We don't have a setGames function. Our useAsyncFunction does not provide a way to set the value externally. But we don't want to modify it because in a real world project we'd probably replace its functionality with react-async.

We can change our useGames custom hook to keep state, and update it whenever the fetchedGames change (or when we call setGames, of course).

export const useGames = () => {
  const [fetchedGames, error, isPending] = useAsyncFunction(getGames, emptyList);

  const [games, setGames] = React.useState(emptyList); // 😎 now we have setGames!
  React.useEffect(() => {
    setGames(fetchedGames);
  }, [fetchedGames]);

  ...
Enter fullscreen mode Exit fullscreen mode

Our useGame hook file now looks like this (see the entire file in the repo)

export const useGames = () => {
  const [fetchedGames, error, isPending] = useAsyncFunction(getGames, emptyList);

  const [games, setGames] = React.useState(emptyList);
  React.useEffect(() => {
    setGames(fetchedGames);
  }, [fetchedGames]);

  const updateGame = (game: Game) => {
    const index = games.findIndex(g => g.id === game.id);
    if (index >= 0) {
      const gamesCopy = games.slice();
      gamesCopy[index] = game;
      setGames(gamesCopy);
    }
  };
  const markAsFinished = (id: number) => {
    setGameStatus(id, 'finished').then(updateGame);
  };

  return { games, error, isPending, markAsFinished };
};
Enter fullscreen mode Exit fullscreen mode

Refactoring

That looks a bit messy. We can extract it to a custom hook:

const useFetchedGames = () => {
  const [fetchedGames, error, isPending] = useAsyncFunction(getGames, emptyList);

  const [games, setGames] = React.useState(emptyList);
  React.useEffect(() => {
    setGames(fetchedGames);
  }, [fetchedGames]);

  return {games, setGames, error, isPending};
}
Enter fullscreen mode Exit fullscreen mode
export const useGames = () => {
  const { games, error, isPending, setGames } = useFetchedGames();
    ...
}
Enter fullscreen mode Exit fullscreen mode

(see the entire file in the repo)

Handling errors

❌ 404 Not Found
Enter fullscreen mode Exit fullscreen mode

Just like before, we've forgotten about handling errors. What happens when the PATCH request fails?

First of all, we have two functions calling the server but only one (getGames) checks the status code of the response.

const getGames = (): Promise<Game[]> => {
  return fetch('http://localhost:3001/games/').then(response => {
    if (response.status !== 200) {
      throw new Error(`${response.status} ${response.statusText}`);
    }
    return response.json();
  });
};

export const setGameStatus = (id: number, status: Game['status']): Promise<Game> => {
  return fetch('http://localhost:3001/games/' + id, {
    method: 'PATCH',
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ status: status })
  }).then(response => response.json()); // 😰 no error handling
};
Enter fullscreen mode Exit fullscreen mode

We don't want to repeat ourselves so we'll extract the error handling to a new function and use it in both cases.

function parseResponse<T>(response: Response): Promise<T> {
  if (response.status !== 200) {
    throw new Error(`${response.status} ${response.statusText}`);
  }
  return response.json();
}

export const getGames = (): Promise<Game[]> => {
  return fetch('http://localhost:3001/games/').then(response =>
    parseResponse(response)
  );
};

export const setGameStatus = (id: number, status: Game['status']): Promise<Game> => {
  return fetch('http://localhost:3001/games/' + id, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ status: status })
  }).then(response => parseResponse(response));
};
Enter fullscreen mode Exit fullscreen mode

To keep things tidy, we move these functions to a new gameClientAPI.ts file (see repo). Our useGames hook imports the functions from it. We're separating concerns and keep our files short.

Now we can catch errors from markAsFinished:

const markAsFinished = (id: number) => {
  setGameStatus(id, 'finished')
    .then(updateGame)
    .catch(error =>
      alert(
        `There was a problem updating this game.\n` +
          `Please try again later.\n\n` +
          `(${error.toString()})`
      )
    );
};
Enter fullscreen mode Exit fullscreen mode

(see repo)

Conclusion

We have successfully wrapped an imperative API in a more declarative API in the form of a custom React hook so it can be used in React function components. When a component needs to access the list of games and make changes to it, it can simply import the useGames hook.

What's next?

This is fine as long as the data is used by only one component. There is no need to have a global(ish) store, or use Redux or MobX. But if more than one component require access to the same data, we should "lift" it to a common ancestor component.

In cases where that common ancestor is not directly the parent of the consimung components, we can avoid prop-drilling by using React Context.

We'll see how we do that in the next article of this series.

Resources

Further reading:

Latest comments (0)