Article summary
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.