Data Fetching with React Query

1
data-fetching-with-react-query
fresher-to-uber
Fresher To Uber

Why React Query

If you've ever used useState and useEffect for data fetching, you already know the drill: fetch data, store it, and pray your app doesn’t explode. Sure, it’s fine for "Hello World" tutorials, but in the real world? It’s like bringing a butter knife to a sword fight.
Ok, let fetching some data from PokéAPI, you probably have seen the code like this before:

export default function PokemonTypeList() {
  const [pokemonTypes, setPokemonTypes] = useState<{ name: string }[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function fetchData() {
      try {
        setIsLoading(true);
        const response = await fetch('https://pokeapi.co/api/v2/type');
        if (!response.ok) throw new Error('Failed to fetch pokemon types');
        const data = await response.json();
        setPokemonTypes(data.results);
      } catch (err) {
        if (err instanceof Error) setError(err.message);
      } finally {
        setIsLoading(false);
      }
    }
    fetchData();
  }, []);

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <ul>
      {pokemonTypes.map(({ name }: { name: string }) => (
        <li key={name}>{name}</li>
      ))}
    </ul>
  );
}

The code looks fine, right? It handles loading, error and success.
But, adding loading and error states to manage fetch lifecycles creates repetitive, verbose code. We can abstract it into a custom hook but it will introduces new problem - data duplication.

In React, component-local state is... local. Every component fetching the same endpoint creates:

  • Multiple requests for the same data.
  • Independent loading and error states in each component.
  • Potential data inconsistencies (e.g., one fetch succeeds, another fails).

Enter React Query – the hero we didn’t deserve but desperately needed. It takes care of all the messy stuff – caching, syncing, loading, errors – so you can stop writing “tutorial code” and start building apps that actually work.

useQuery

Setup

First we need to create QueryClient and pass it to QueryClientProvider, then wrap your main app component by it. This will make the QueryCache available to you anywhere in your component tree.

import { 
  QueryClient, 
  QueryClientProvider 
} from '@tanstack/react-query'

const queryClient = new QueryClient()

export default function App () {
  return (
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  )
}

useQuery received an object with two main things: a queryKey and a queryFn. By default, useQuery will return that data immediately from cache if it find the data at the queryKey.

Otherwise, it will invoke the queryFn, take the data that resolved from response, put it in the cache at the queryKey, and then return it.

const { data, status, isLoading } = useQuery({
  queryKey: ["pokemon-types"],
  queryFn: () => getPokemonTypes(),
})

Let re-write the PokemonTypeList with useQuery

async function getPokemonTypes(): Promise<string[]> {
  const res = await fetch("https://pokeapi.co/api/v2/type");
  const types = await res.json()
  return types.results?.map(({ name }: { name: string }) => name)
}

function usePokemonTypes() {
  return useQuery({
    queryKey: ["pokemon-types"],
    queryFn: () => getPokemonTypes()
  })
}

export default function PokemonTypeList() {
  const { data, status } = usePokemonTypes();

  if (status === "pending") {
    return (
      // loading component
    )
  }

  if (status === "error") {
    return (
      // error component
    )
  }

  return (
    <ul>
      {data?.map(({ name }: { name: string }) => (
        <li key={name}>{name}</li>
      ))}
    </ul>
  )
}

In addition to providing the fetched data, useQuery exposes its internal state, allowing you to track the current status of the query:

  • pending: The query is still in progress.
  • success: The query has completed successfully.
  • error: The query encountered an issue.

There are another options which is via the derived boolean flags: isPending, isSuccess and isError. Whichever one you prefer, it's up to you.

const { data, status } = usePokemonTypes();

if (status === "pending") {
  return (
    // loading component
  )
}

if (status === "error") {
  return (
    // error component
  )
}

if (status === "success") {
  return (
    // render component with data
  )
}

  // or

const { data, isPending, isError, isSuccess } = usePokemonTypes();

if (isPending) {
  return (
    // loading component
  )
}

if (isError) {
  return (
    // error component
  )
}

if (isSuccess) {
  return (
    // render component with data
  )
}

fetching on demand

The example Pokémon types above will fetching data immediately when the component mounted. What if we needed user input before making the request? How would that scenario be handled with useQuery?
Let's create a search input that takes user input and displays a list of Pokémon cards.

// App.tsx
function App() {
  const [search, setSearch] = React.useState(''
  
  return (
    <QueryClientProvider client={queryClient}>
      <div>
        <Input
          type="text"
          placeholder="Search pokemon"
          value={search}
          onChange={(e) => setSearch(e.target.value)} />
        <PokemonList search={search} />
      </div>
    </QueryClientProvider>
  )
}

// PokemonList component
function usePokemons(search: string) {
  return useQuery({
    queryKey: ['pokemons', search],
    queryFn: () => fetchPokemons(search),
    enabled: !!search
  })
}

function PokemonList({
  search
}: { 
  search: string
}) {
  const { data, status, isLoading } = usePokemons(search);

  if (isLoading) {
    return (
      // loading component
    )
  }

  if (status === "error") {
    return (
      // error component
    )
  }

  if (status === "success") {
    return (
      <div>
        {data.map((pokemon) => {
          return (
            <PokemonCard key={pokemon.id} pokemon={pokemon} />
          )
        })}
      </div>
    )
  }

  return (
    <div>
      No Pokemons founded!
    </div>
  )
}

React Query provides the enabled property as a configuration option for useQuery.

The enabled property accepts a boolean value that controls whether the query function should execute.

In our case, we can use enabled to tell React Query to run the queryFn only when a search term is available.

You might notice we use a new state isLoading to show the loading indicator. Why?

const { data, status, isLoading } = usePokemons(search);

  if (isLoading) {
    return (
      // loading component
    )
  }

Before, in order to show our loading indicator, we're checking if the query is in a pending state.

if (status === "pending") {
  return (
    // loading component
  )
}

However, the pending status only indicates that there's no data in the cache and no error occurred while fetching. It doesn’t actually confirm if the query is currently fetching. By using it as the condition for our loading indicator, we're making an incorrect assumption. As a result, the user sees the loading indicator even before typing anything into the input field.

React Query simplifies this by exposing a isLoading property on the query object. When the status queryFn is actively running and combined with the query's status, allows us to accurately determine when to display a loading indicator.
Now, you're able to search pokémon just fine.

When react-query refetch the data?

As we know react-query save data in the cache and return it immediately if it's available. The question is when data is stale, how react-query refetch the data and update the cache?

The answer is:

1. The queryKey changes

When a query is stale, React Query serves the cached data first, then seamlessly resynchronizes in the background to update the cache. Additionally, if the queryKey changes and the query is stale, React Query will automatically refetch the data and refresh the cache.

2. New observer (component) mount

Whenever a new observer is created (e.g., through useQuery in a newly mounted component), React Query checks if the query is stale. If it is, it automatically refetches the data and updates the cache. This happens seamlessly when users navigate to a new screen or open a dialog in your SPA.

3. The window receives a focus event

Whenever a user switches back to the tab where the app is running, React Query automatically checks if the query is stale. If it is, it refetch the data and updates the cache seamlessly.

4. Device Goes Online

React Query also enhances user experience when network conditions change. If a device goes offline and later reconnects, React Query detects the reconnection. If the query is stale, it will automatically refetch the data and update the cache, ensuring the user sees the latest information.

Conclusion

React Query is a game-changer for managing server state in React apps. Don’t wait - start using React Query today!

And again you can find the example .

1