TanStack Query

useQuery caches by key, dedupes, retries and refetches in the background, replacing the effect-and-three-states code written by hand.

Syntaxconst { data, isPending, isError, error, refetch } = useQuery({ queryKey, queryFn });

TanStack Query (@tanstack/react-query, version 5) is a cache for server data. You give it a key and a function that returns a promise; it stores the result under that key, hands the same data to every component asking for it, and refetches when the data goes stale. It replaces the effect, the three state variables, the abort handling and the retry loop with one hook call.

Setting it up

Create one QueryClient for the app and wrap the tree in QueryClientProvider. Any component can then call useQuery({ queryKey, queryFn }) and read data, isPending, isError, error, isFetching and refetch. Note the property shorthand: { queryKey, queryFn } is the same object as { queryKey: queryKey, queryFn: queryFn }.

The key is the identity

The key is an array, hashed stably, and it doubles as a dependency list: change ['notes', id] and you get a different cache entry and a new fetch. Anything the query function reads must appear in the key.

Defaults, and what React Native changes

staleTime is 0, so data is stale as soon as it arrives; gcTime is five minutes; queries retry three times, mutations none. refetchOnWindowFocus defaults to true, but there is no window in a native app, so it does nothing until you forward AppState changes to focusManager. Refetching on reconnect needs onlineManager wired to a network listener the same way.

Example

Example · tsx
import { useQuery } from '@tanstack/react-query';
import { ActivityIndicator, FlatList, StyleSheet, Text } from 'react-native';

type Note = { id: string; body: string };

async function fetchNotes(): Promise<Note[]> {
  const response = await fetch('https://api.example.com/notes');
  // A query function must throw on failure; a returned error object counts as success.
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return (await response.json()) as Note[];
}

export default function NotesScreen() {
  const { data, isPending, isError, error, isFetching, refetch } = useQuery({
    queryKey: ['notes'], // cache identity
    queryFn: fetchNotes, // same as queryFn: () => fetchNotes()
    staleTime: 30_000,   // 30s of trust; the default is 0
  });

  // isPending means nothing is cached yet. isFetching is also true for background work.
  if (isPending) return <ActivityIndicator style={styles.fill} />;
  if (isError) return <Text style={styles.row}>{error.message}</Text>;

  return (
    <FlatList
      data={data}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text style={styles.row}>{item.body}</Text>}
      ListEmptyComponent={<Text style={styles.row}>No notes yet</Text>}
      refreshing={isFetching}
      onRefresh={() => refetch()}
    />
  );
}

const styles = StyleSheet.create({
  fill: { flex: 1, justifyContent: 'center' },
  row: { padding: 16 },
});

When to use it

  • Show a list instantly on a second visit while it refreshes in the background.
  • Let three components read the same endpoint without three requests going out.
  • Refresh the current screen's data when the app returns from the background.
  • Delete the hand-written loading, error, retry and cancel code from a screen.

More examples

The client, and teaching it about app focus

The provider belongs at the root layout so every screen shares one cache. The AppState bridge is the piece web tutorials never mention: without it, returning to the app after an hour shows hour-old data until something else triggers a fetch.

Example · tsx
// app/_layout.tsx
import { useEffect, useState } from 'react';
import { AppState, Platform } from 'react-native';
import type { AppStateStatus } from 'react-native';
import { Stack } from 'expo-router';
import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query';

export default function RootLayout() {
  // Created once for the session. A client built inline would be new on every render.
  const [client] = useState(
    () =>
      new QueryClient({
        defaultOptions: { queries: { retry: 2, staleTime: 60_000 } },
      })
  );

  useEffect(() => {
    // There is no window focus event here, so forward AppState instead.
    const subscription = AppState.addEventListener('change', (status: AppStateStatus) => {
      if (Platform.OS !== 'web') focusManager.setFocused(status === 'active');
    });
    return () => subscription.remove();
  }, []);

  return (
    <QueryClientProvider client={client}>
      <Stack />
    </QueryClientProvider>
  );
}

A key with a parameter, and a query that waits

The pattern for any detail screen. Because the id is in the key, two notes get two cache entries and going back to the first one shows it instantly. enabled covers the case where the id has not arrived yet, which is better than a query function that fetches /notes/undefined.

Example · tsx
import { useQuery } from '@tanstack/react-query';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';

type Note = { id: string; body: string };
type NoteScreenProps = { noteId?: string };

async function fetchNote(id: string): Promise<Note> {
  const response = await fetch(`https://api.example.com/notes/${id}`);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return (await response.json()) as Note;
}

export default function NoteScreen({ noteId }: NoteScreenProps) {
  const { data, isPending } = useQuery({
    queryKey: ['notes', noteId],       // the id is part of the identity
    queryFn: () => fetchNote(noteId!), // only called while enabled is true
    enabled: noteId !== undefined,     // no id yet: do not fetch
  });

  if (!noteId) return <Text style={styles.row}>Pick a note</Text>;
  if (isPending) return <ActivityIndicator style={styles.row} />;

  return (
    <View style={styles.row}>
      <Text>{data?.body}</Text>
    </View>
  );
}

const styles = StyleSheet.create({ row: { padding: 16 } });

Refetching when the connection returns

Query assumes the browser's online and offline events, which do not exist here. Wiring onlineManager to expo-network buys two behaviours: queries pause instead of failing while the device is offline, and stale ones refetch as soon as the connection is back.

Example · tsx
// query/online.ts — imported once, near the top of the app
import { onlineManager } from '@tanstack/react-query';
import * as Network from 'expo-network';

onlineManager.setEventListener((setOnline) => {
  const subscription = Network.addNetworkStateListener((state) => {
    setOnline(!!state.isConnected);
  });
  return subscription.remove; // the unsubscribe function, called on teardown
});

Discussion

  • Be the first to comment on this lesson.