Maintaining Order with Apollo Query Hooks

On my current React project, we are using the hooks provided by Apollo to fetch and mutate data with GraphQL. One of the components I wrote had a query that relied on data from a previous query. The component looked similar to this example:

export const BadSelect = () => {
  const [selectedPokemon, selectPokemon] = React.useState<Pokemon | null>(null);
  const [selectedAttack, selectAttack] = React.useState<PokemonAttack | null>(
    null
  );
  const pokemonNameQuery = useQuery<{ pokemons: Pokemon[] }>(
    GET_ALL_POKEMON_NAMES
  );
  if (pokemonNameQuery.data === undefined) {
    return <p>Loading</p>;
  }
  if (!selectedPokemon) {
    return (
      <PokemonNameSelector
        pokemons={pokemonNameQuery.data.pokemons}
        selectedPokemon={selectedPokemon}
        onSelect={selectPokemon}
      />
    );
  }
  const pokemonAttackQuery = useQuery<{
    pokemon: {
      attacks: PokemonAttacks;
    };
  }>(GET_POKEMON_ATTACKS, {
    variables: {
      name: selectedPokemon.name,
    },
  });
  return (
    <>
      <PokemonNameSelector
        pokemons={pokemonNameQuery.data.pokemons}
        selectedPokemon={selectedPokemon}
        onSelect={selectPokemon}
      />
      <PokemonAttackSelector
        attacks={
          pokemonAttackQuery.data?.pokemon.attacks || { fast: [], special: [] }
        }
        onAttackSelected={selectAttack}
        selectedAttack={selectedAttack}
      />
    </>
  );
};

This looks fine except for one issue: not all of the hooks are called on every render. Since React hooks depend on call order, this component has a high potential for bugs. After some research, we discovered two potential ways to get around this issue.

The skip Parameter

One option is to provide the skip parameter to the useQuery call. This causes the query to be skipped as long as the parameter is true. Here’s an example of the above component refactored to use the skip parameter:

export const SkipSelect = () => {
  const [selectedPokemon, selectPokemon] = React.useState<Pokemon | null>(null);
  const [selectedAttack, selectAttack] = React.useState<PokemonAttack | null>(
    null
  );
  const pokemonNameQuery = useQuery<{ pokemons: Pokemon[] }>(
    GET_ALL_POKEMON_NAMES
  );
  const pokemonAttackQuery = useQuery<{
    pokemon: {
      attacks: PokemonAttacks;
    };
  }>(GET_POKEMON_ATTACKS, {
    variables: {
      name: selectedPokemon?.name || "",
    },
    skip: selectedPokemon === null,
  });
  if (pokemonNameQuery.data === undefined) {
    return <p>Loading</p>;
  }
  if (!selectedPokemon) {
    return (
      <PokemonNameSelector
        pokemons={pokemonNameQuery.data.pokemons}
        selectedPokemon={selectedPokemon}
        onSelect={selectPokemon}
      />
    );
  }
  return (
    <>
      <PokemonNameSelector
        pokemons={pokemonNameQuery.data.pokemons}
        selectedPokemon={selectedPokemon}
        onSelect={selectPokemon}
      />
      <PokemonAttackSelector
        attacks={
          pokemonAttackQuery.data?.pokemon.attacks || { fast: [], special: [] }
        }
        onAttackSelected={selectAttack}
        selectedAttack={selectedAttack}
      />
    </>
  );
};

The pokemonAttackQuery is now called at the top level of the component, and all of our hooks are called in the same order on every render. This is great, and it works, but there’s one thing that I don’t like about this solution. The skip parameter doesn’t guarantee that the value we pass to the name variable is defined. This will probably work, but something about it makes me feel uneasy, so I wanted another solution.

The useLazyQuery Hook

Apollo also provides useLazyQuery, which allows you to defer the call to the GraphQL API to another part of your code. Here’s an example of the above component refactored to use the useLazyQuery hook:

export const LazySelect = () => {
  const [selectedPokemon, selectPokemon] = React.useState<Pokemon | null>(null);
  const [selectedAttack, selectAttack] = React.useState<PokemonAttack | null>(
    null
  );
  const pokemonNameQuery = useQuery<{ pokemons: Pokemon[] }>(
    GET_ALL_POKEMON_NAMES
  );
  const [
    getSelectedPokemonAttacks,
    { data: pokemonAttackQueryData },
  ] = useLazyQuery<{
    pokemon: {
      attacks: PokemonAttacks;
    };
  }>(GET_POKEMON_ATTACKS, {});
  React.useEffect(() => {
    if (selectedPokemon) {
      getSelectedPokemonAttacks({
        variables: {
          name: selectedPokemon.name,
        },
      });
    }
  }, [selectedPokemon]);
  if (pokemonNameQuery.data === undefined) {
    return <p>Loading</p>;
  }
  if (!selectedPokemon) {
    return (
      <PokemonNameSelector
        pokemons={pokemonNameQuery.data.pokemons}
        selectedPokemon={selectedPokemon}
        onSelect={selectPokemon}
      />
    );
  }
  return (
    <>
      <PokemonNameSelector
        pokemons={pokemonNameQuery.data.pokemons}
        selectedPokemon={selectedPokemon}
        onSelect={selectPokemon}
      />
      <PokemonAttackSelector
        attacks={
          pokemonAttackQueryData?.pokemon.attacks || { fast: [], special: [] }
        }
        onAttackSelected={selectAttack}
        selectedAttack={selectedAttack}
      />
    </>
  );
};

Once again we have allowed our hooks to be called in the same order on every render, while also keeping type safety.

Conclusion

I hope this helps you properly order your Apollo query hooks. If you would like more information on why hooks need to be called in the same order on every render, I’d highly recommend reading Dan Abramov’s blog post. I would also recommend going over the documentation on Apollo’s query hooks.

A working version of this code can be seen in this GitHub repository.