Going Pro: Job-Ready React NativePro· 50 min read

Server Data with TanStack Query

Raw fetch in useEffect means juggling loading, error and refetch by hand — TanStack Query does all of it for you and caches the result.

What you will learn

  • Explain why server data needs more than useState
  • Fetch with useQuery and get loading/error/data for free
  • Refetch, cache and send data back with useMutation

Why raw fetch gets messy

Earlier you fetched data with fetch inside useEffect, plus a loading flag and an error state you managed by hand. That works for one screen, but real apps do this on every screen and quickly need more: show a cached result instantly, refetch when the user pulls to refresh, avoid re-fetching the same thing twice, retry on a flaky network. Writing all that yourself, everywhere, is a lot of fiddly code.

Server state is data that actually lives on a server — users, products, posts — not in your app. It is different from normal local state because it can go stale (the server’s copy changed) and must be re-fetched. TanStack Query (also called React Query) is the modern standard for handling exactly this: it fetches, caches (remembers the last result so it shows instantly next time), tracks loading and error states, and refetches when needed — all from a couple of hooks.

One-time setup

Install the library, then wrap your app once in a QueryClientProvider (the holder for the shared cache):

Install TanStack Query
npx expo install @tanstack/react-query

Note: Output: (Adds the package. Nothing on screen yet.)

Next, create one QueryClient (the object that owns the cache) and wrap your whole app in a QueryClientProvider that hands it down. You do this once, at the very top — every screen inside the provider can then use queries and share the same cache:

Wrap the app once so every screen can use queries
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

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

Note: Output: No screen output — this creates the shared cache (queryClient) and makes it available to every screen inside the provider.

Fetching with useQuery

Now the payoff. useQuery takes a queryKey (a unique label for this data, used as its cache entry) and a queryFn (a function that fetches and returns the data). In return it hands you data, isLoading and isError — the three states you used to manage by hand:

useQuery returns loading, error and data — no useEffect, no manual flags
import { useQuery } from '@tanstack/react-query';
import { FlatList, Text, ActivityIndicator } from 'react-native';

function UsersScreen() {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['users'],
    queryFn: () =>
      fetch('https://jsonplaceholder.typicode.com/users').then((r) => r.json()),
  });

  if (isLoading) return <ActivityIndicator size="large" />;
  if (isError) return <Text>Could not load users.</Text>;

  return (
    <FlatList
      data={data}
      keyExtractor={(u) => String(u.id)}
      renderItem={({ item }) => <Text style={{ padding: 12 }}>{item.name}</Text>}
    />
  );
}

Note: Output: A spinner for a moment, then a scrollable list of names like: Leanne Graham Ervin Howell ... Compare with the earlier raw-fetch lesson: there is no useEffect, no useState for loading or data, no [] dependency to remember. useQuery provides isLoading, isError and data for you.

The caching win, step by step

The biggest difference is the cache. Here is what happens the second time you visit this screen, in order:

  1. The first visit fetches the users and stores the result in the cache under the key ['users'].
  2. You navigate away to another tab. The data stays in the cache.
  3. You come back to the users screen. useQuery finds ['users'] already in the cache and shows it instantly — no spinner, no blank screen.
  4. In the background, if the data is considered stale, it quietly re-fetches and updates the list if anything changed.
  5. Because the key is shared, two different screens asking for ['users'] reuse the same cached data instead of fetching twice.

Refetch and pull-to-refresh

useQuery also gives you a refetch function and an isRefetching flag, which plug straight into a FlatList’s pull-to-refresh:

Pull-to-refresh wired up with refetch and isRefetching
const { data, refetch, isRefetching } = useQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
});

<FlatList
  data={data}
  onRefresh={refetch}
  refreshing={isRefetching}
  keyExtractor={(u) => String(u.id)}
  renderItem={({ item }) => <Text>{item.name}</Text>}
/>

Note: Output: Pull the list down -> a spinner shows (isRefetching is true) while refetch runs -> the list updates with fresh data. You wrote no extra state for this.

Sending data back with useMutation

useQuery is for reading data. To change data on the server (create, update, delete) you use useMutation. It gives you a mutate function to call, and you can tell it to refresh related queries afterwards so the screen shows the new data:

useMutation posts new data, then invalidates the list to refresh it
import { useMutation, useQueryClient } from '@tanstack/react-query';

function useAddUser() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (name) =>
      fetch('https://api.example.com/users', {
        method: 'POST',
        body: JSON.stringify({ name }),
      }),
    onSuccess: () => {
      // refresh the users list so the new one appears
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

Note: Output: Calling mutate('Rohan') POSTs the new user. On success, invalidateQueries marks ['users'] stale, so that list re-fetches and Rohan appears — no manual state shuffling.

Tip: A good mental split: useQuery to read server data, useMutation to write it. After a mutation, invalidateQueries is the usual way to tell the matching query to refresh.

Watch out: Keep using plain useState for UI state (a text box, a modal’s open/closed flag). TanStack Query is for server state — data that comes from and goes back to an API. Mixing the two up leads to confusing code.

Q. What does useQuery give you that a raw fetch in useEffect does not?

Answer: useQuery provides isLoading, isError and data for you and caches the result by its query key, so revisiting a screen shows data instantly and avoids duplicate fetches.

✍️ Practice

  1. Convert the earlier raw-fetch users screen to useQuery and delete the useState/useEffect you no longer need.
  2. Add pull-to-refresh to a FlatList using refetch and isRefetching.

🏠 Homework

  1. Fetch a list from any public API with useQuery, show loading and error states, and add a refetch button that re-runs the query.
Want to learn this with a mentor?

CodingClave runs guided, project-based training (28-day, 45-day & 6-month batches).

Explore Training →