TanStack Query v5 to biblioteka do zarządzania server state w React i React Native, która automatycznie cache'uje, deduplikuje i odświeża dane pobierane z API. W wersji mobilnej wymaga dodatkowej konfiguracji focusManager z AppState oraz onlineManager z NetInfo, ponieważ React Native nie ma zdarzeń window.focus ani navigator.onLine. Ten przewodnik pokazuje, jak w 2026 roku poprawnie skonfigurować TanStack Query v5 w projekcie Expo lub bare React Native, dodać persystencję cache przez MMKV, pisać mutacje optymistyczne i zmigrować z v4.
TanStack Query v5 wymaga w React Native ręcznego podłączenia focusManager (przez AppState) oraz onlineManager (przez @react-native-community/netinfo lub expo-network). Bez tego zapytania nie odświeżają się po powrocie do aplikacji ani nie wznawiają po odzyskaniu sieci.
Status loading został przemianowany na pending, a cacheTime na gcTime. Wartość isLoading teraz oznacza tylko pierwszy fetch (status === 'pending' && fetchStatus === 'fetching').
Do persystencji cache między restartami aplikacji użyj PersistQueryClientProvider z adapterem MMKV. Biblioteka react-native-mmkv jest do 30× szybsza od AsyncStorage i ma synchroniczne API.
Nowe hooki v5 (useSuspenseQuery, useSuspenseInfiniteQuery, useMutationState) upraszczają obsługę stanów ładowania i współdzielenie stanu mutacji między komponentami.
Mutacje optymistyczne w v5 można pisać bez ręcznej aktualizacji cache. Wystarczy odczytać variables ze zwróconej mutacji i renderować je w tymczasowym rzędzie listy.
React Query DevTools zostały przepisane od zera i są teraz dostępne dla React Native przez pakiet @dev-plugins/react-query integrujący się z nowym React Native DevTools.
Czym jest TanStack Query i dlaczego warto używać go w React Native
Kiedy przyszłam do React Native z React Web, moim pierwszym odruchem było wrzucenie wszystkiego do Redux Toolkit lub Zustand, bo tak robiłam na frontendzie. Szybko zauważyłam, że 80% mojego "globalnego stanu" to w rzeczywistości server state: dane, które mają źródło prawdy na backendzie i wymagają invalidacji, retry i deduplikacji, a nie tylko trzymania w pamięci. Dokładnie tym problemem zajmuje się TanStack Query.
W przeglądarce biblioteka działa "out of the box". Nasłuchuje window.focus, navigator.onLine i sama refetch'uje zapytania. W React Native te API nie istnieją. Zamiast tego mamy AppState (aktywność aplikacji) i NetInfo lub expo-network (stan sieci). TanStack Query udostępnia dwa "menedżery" (focusManager i onlineManager), do których podpina się te API. Bez tego zapytania po wznowieniu aplikacji z tła są przestarzałe, a mutacje w trybie offline gubione bez próby ponowienia.
W porównaniu do rozwiązań globalnego stanu jak Zustand, Redux Toolkit czy Jotai, TanStack Query nie zastępuje ich. On je uzupełnia. Zustand trzymaj do stanu UI (motyw, filtry, otwarte modale), TanStack Query do wszystkiego, co przychodzi z sieci. Rozdzielenie tych dwóch warstw to najważniejsza zmiana architektoniczna, jaką wprowadziłam w ostatnich projektach mobilnych.
Instalacja TanStack Query v5 w Expo i bare React Native
Instalacja jest identyczna jak na webie, dodatkowo doinstalowujemy adapter sieci. W projekcie Expo (SDK 51+) używam:
W bare React Native (0.80+ z Nową Architekturą, patrz przewodnik migracji na React Native 0.80) używamy zwykłego npm lub yarn, a po instalacji react-native-mmkv wymagany jest pod install na iOS. MMKV korzysta z JSI, więc nie działa w klasycznym Remote JS Debuggerze. Od RN 0.76 musisz i tak używać nowego DevTools Hermes.
Minimalny setup QueryClient w App.tsx lub app/_layout.tsx (Expo Router):
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export default function RootLayout() {
// useState zapewnia, że klient nie jest tworzony na każdym renderze
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// retry z odczekaniem, bo sieć mobilna bywa kapryśna
retry: 2,
// dane uznajemy za świeże przez 30 s
staleTime: 30 * 1000,
// trzymamy w cache 5 minut po ostatnim użyciu (dawniej cacheTime)
gcTime: 5 * 60 * 1000,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{/* reszta drzewa nawigacji */}
</QueryClientProvider>
);
}
Konfiguracja focusManager z AppState (refetch po powrocie aplikacji)
React Native nie ma zdarzenia "focus okna", bo użytkownik przełącza się między aplikacjami, nie kartami. Zamiast tego AppState emituje active, background i inactive. Podpinamy to do focusManager, żeby TanStack Query wiedział, kiedy odświeżyć przestarzałe zapytania.
import { focusManager } from '@tanstack/react-query';
import { AppState, Platform } from 'react-native';
import type { AppStateStatus } from 'react-native';
import { useEffect } from 'react';
function onAppStateChange(status: AppStateStatus) {
// React Query obsługuje focus na webie sam; pomijamy platformę web
if (Platform.OS !== 'web') {
focusManager.setFocused(status === 'active');
}
}
export function useAppStateFocus() {
useEffect(() => {
const sub = AppState.addEventListener('change', onAppStateChange);
return () => sub.remove();
}, []);
}
Wywołuję useAppStateFocus() raz, w komponencie root layout, tuż pod QueryClientProvider. Od tego momentu każde useQuery z refetchOnWindowFocus: true (domyślnie włączone) odświeży się, gdy użytkownik wróci do aplikacji z tła. Szczerze mówiąc, to było najczęstsze pytanie "dlaczego mój ekran pokazuje stare dane po powrocie z ustawień systemu" na Stack Overflow. Teraz masz odpowiedź w trzech linijkach.
Jeśli chcesz odświeżać dane per ekran (np. przy powrocie do konkretnego tabu w Expo Router), użyj dodatkowo useFocusEffect z @react-navigation/native:
import { useFocusEffect } from '@react-navigation/native';
import { useCallback, useRef } from 'react';
export function useRefetchOnScreenFocus(refetch: () => void) {
const firstTimeRef = useRef(true);
useFocusEffect(
useCallback(() => {
// useFocusEffect odpala też przy montowaniu; pomijamy pierwszy raz
if (firstTimeRef.current) {
firstTimeRef.current = false;
return;
}
refetch();
}, [refetch])
);
}
onlineManager z NetInfo i obsługa trybu offline
Kiedy urządzenie traci sieć, TanStack Query automatycznie pauzuje zapytania i mutacje, a po odzyskaniu połączenia je wznawia. Musi jednak wiedzieć, że sieć zniknęła. W przeglądarce zajmuje się tym navigator.onLine; w RN dopinamy NetInfo.
import { onlineManager } from '@tanstack/react-query';
import NetInfo from '@react-native-community/netinfo';
// Wywołaj RAZ przy starcie aplikacji (poza komponentem)
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
// isInternetReachable bywa null na starcie; traktujemy null jako online
setOnline(!!state.isConnected && state.isInternetReachable !== false);
});
});
Dla projektów w pełni na Expo można użyć expo-network, który nie wymaga dodatkowego autolinkowania:
import { onlineManager } from '@tanstack/react-query';
import * as Network from 'expo-network';
onlineManager.setEventListener((setOnline) => {
const subscription = Network.addNetworkStateListener(({ isConnected }) => {
setOnline(!!isConnected);
});
// stan początkowy, z fallbackiem, bo API bywa niestabilne na starych SDK
Network.getNetworkStateAsync()
.then((s) => setOnline(!!s.isConnected))
.catch(() => setOnline(true));
return () => subscription.remove();
});
Jak persystować cache w React Native: AsyncStorage vs MMKV
Domyślnie po zabiciu procesu aplikacji cały cache znika. Użytkownik otwiera ekran feedu i przez pierwsze 300 ms widzi spinner, mimo że dane pobierał 10 sekund temu. Rozwiązaniem jest PersistQueryClientProvider, który serializuje cache do trwałego storage.
Do wyboru mamy dwa persistery:
Cecha
AsyncStorage
MMKV
Prędkość
Standardowa (asynchroniczna)
Do 30× szybsza (synchroniczna, JSI)
API
Promise-based
Synchroniczne
Szyfrowanie
Brak
AES-128/256 wbudowane
Persister TanStack
createAsyncStoragePersister
createSyncStoragePersister
Wsparcie Expo Go
Tak
Nie (wymaga dev client)
Rekomendacja 2026
Prototypy, Expo Go
Produkcja
Wariant produkcyjny z MMKV wygląda tak:
import { MMKV } from 'react-native-mmkv';
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
const storage = new MMKV({ id: 'react-query-cache' });
const clientStorage = {
setItem: (key: string, value: string) => storage.set(key, value),
getItem: (key: string) => storage.getString(key) ?? null,
removeItem: (key: string) => storage.delete(key),
};
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// gcTime musi być >= maxAge persistera, inaczej dane wypadną z cache
gcTime: 1000 * 60 * 60 * 24, // 24 h
},
},
});
const persister = createSyncStoragePersister({ storage: clientStorage });
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
>
{children}
</PersistQueryClientProvider>
);
}
Warto pamiętać, że createSyncStoragePersister i createAsyncStoragePersister throttlują zapisy (maksymalnie raz na sekundę), więc nie zabijesz baterii nawet przy szybko zmieniających się danych. Zgodnie z oficjalną dokumentacją TanStack Query ta wartość jest konfigurowalna przez opcję throttleTime.
Mutacje optymistyczne w v5 (nowe API bez ręcznej aktualizacji cache)
W v4 optymistyczna aktualizacja wymagała trzech kroków w onMutate: cancel query, snapshot cache, ręczna aktualizacja przez setQueryData, plus rollback w onError. W v5 dla wielu przypadków wystarczy odczytać variables z listy zwróconej przez useMutationState lub bezpośrednio z instancji mutacji i wyrenderować je jako tymczasowy wiersz. Kod robi się dramatycznie krótszy.
Rollback dzieje się automatycznie. Jeśli mutacja rzuci wyjątek, "duch" znika z listy razem z resetem variables. Do bardziej złożonych scenariuszy (edycja istniejącego rekordu) nadal użyjesz onMutate z setQueryData, ale dla dodawania i usuwania nowy pattern eliminuje jakieś 40 linii boilerplate.
useSuspenseQuery i useInfiniteQuery: nowoczesny pattern list
W v5 useSuspenseQuery to samodzielny hook zamiast opcji suspense: true. Zaletą jest to, że TypeScript wie, iż data nigdy nie będzie undefined, więc nie musisz opróżniać ekranu warunkami data ??. Wymaga jednak <Suspense fallback={...}> gdzieś wyżej w drzewie.
import { Suspense } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ActivityIndicator, Text } from 'react-native';
function Profile({ userId }: { userId: string }) {
// data ma typ User, nie User | undefined
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <Text>{data.name}</Text>;
}
export function ProfileScreen({ userId }: { userId: string }) {
return (
<Suspense fallback={<ActivityIndicator />}>
<Profile userId={userId} />
</Suspense>
);
}
Dla nieskończonych list (feedy, wyszukiwanie, komentarze) łącz useInfiniteQuery z FlashList v2 (porównanie wydajności list), żeby nie tracić na scrollu przy tysiącach elementów. W v5 useInfiniteQuery pozwala prefetch'ować wiele stron naraz przez opcję pages, co świetnie sprawdza się przy powrocie z detali do listy.
DevTools dla React Native (nowa wersja z inline editing)
DevTools w v5 zostały przepisane od zera w sposób framework-agnostyczny. W React Native nie odpalisz ich jako pływającego panelu (nie ma DOM), ale integrują się z nowym React Native DevTools (przewodnik po Flipperze i następcach) przez plugin @dev-plugins/react-query:
npm install @dev-plugins/react-query
import { useReactQueryDevTools } from '@dev-plugins/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function DevToolsBridge({ client }: { client: QueryClient }) {
useReactQueryDevTools(client);
return null;
}
export function AppProviders({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{__DEV__ && <DevToolsBridge client={queryClient} />}
{children}
</QueryClientProvider>
);
}
Po uruchomieniu npx expo start lub npx react-native start, otwórz DevTools klawiszem j w Metro. Zobaczysz zakładkę React Query z inline editing danych cache, ręcznym invalidate'em i podglądem query timelines. To jedna z najbardziej niedocenianych zmian v5, bo debugowanie stanu server-state stało się na parytecie z web.
Najczęstsze pułapki przy migracji z v4 na v5
Zespół TanStack dostarcza codemod (npx @tanstack/react-query-codemod v5/is-loading), ale kilka rzeczy trzeba dopilnować ręcznie. Poniżej lista, na której się potknęłam osobiście lub widziałam kolegów.
cacheTime → gcTime: nazwa lepiej odzwierciedla mechanizm garbage collection.
Hydrate → HydrationBoundary: jeśli używasz SSR na webie (RN nie dotyczy).
Usunięcie suspense: true: użyj useSuspenseQuery lub useSuspenseInfiniteQuery.
Zmiany semantyczne, które łamią logikę
isLoading to teraz tylko pierwszy fetch (status === 'pending' && fetchStatus === 'fetching'). Jeśli używałeś go do "cokolwiek się dzieje", zamień na isFetching. To najczęstszy powód "zniknął mój loading spinner po pierwszym renderze".
status: 'loading' → 'pending'. Jeśli robisz if (status === 'loading'), kod dalej się skompiluje (string), ale nigdy nie trafi w warunek.
Mutations nie mają już callbacków w useMutation({ onSuccess, ...}) jako drugi argument. Wszystko idzie do jednego obiektu.
Rzeczy, których codemod NIE zrobi
Codemod nie doda konfiguracji focusManager ani onlineManager. To musisz zrobić ręcznie zgodnie z sekcjami wyżej. Nie przepnie też PersistQueryClient na v5 API, jeśli używałeś eksperymentalnego experimental_createPersister per-query. W testach, jeśli używasz waitFor(() => expect(result.current.isLoading).toBe(false)), zmień na isPending, inaczej testy się zapętlą. Osobiście straciłam na tym godzinę przy pierwszej migracji, więc powtarzam: isPending, nie isLoading.
Najczęściej zadawane pytania
Czy TanStack Query zastępuje Redux lub Zustand w React Native?
Nie. TanStack Query zarządza server state (dane z API), a Redux/Zustand/Jotai client state (UI, motyw, formularze). W praktyce używa się obu równolegle: TanStack Query do wszystkiego, co przychodzi z sieci, i lekki store (Zustand) do stanu interfejsu. Dzięki temu twój globalny store kurczy się do 10-20% tego, co miał wcześniej.
Jak sprawdzić, czy urządzenie jest offline w React Query?
Zaimportuj onlineManager z @tanstack/react-query i wywołaj onlineManager.isOnline(), żeby dostać aktualny stan. Do reaktywnego renderowania użyj hooka useIsRestoring lub subskrybuj onlineManager.subscribe(callback). Pamiętaj, że wartość jest wiarygodna tylko po podpięciu NetInfo lub expo-network.
Czy MMKV działa w Expo Go?
Nie. react-native-mmkv to natywna biblioteka JSI, więc wymaga custom development client (npx expo prebuild lub eas build --profile development). W samym Expo Go użyj createAsyncStoragePersister z @react-native-async-storage/async-storage. Zawsze możesz zamienić persister później bez zmian w warstwie zapytań.
Dlaczego moje dane nie odświeżają się po powrocie do aplikacji z tła?
Prawdopodobnie nie podpiąłeś focusManager do AppState. TanStack Query w React Native nie wie o window.focus, więc musisz ręcznie wywołać focusManager.setFocused(status === 'active') w listenerze AppState.addEventListener('change', ...). Setup mieści się w 10 liniach kodu w root komponencie.
Jak testować hooki TanStack Query w Jest?
Owiń komponent w QueryClientProvider z nową instancją QueryClient per test (żeby uniknąć wycieków cache między testami). Wyłącz retry: defaultOptions: { queries: { retry: false } }, inaczej testy będą wolne. Do asercji stanu użyj waitFor z @testing-library/react-native, sprawdzając isPending zamiast dawnego isLoading.
Praktyczny przewodnik po Maestro E2E dla React Native w 2026: instalacja w Expo, pierwszy scenariusz YAML, Maestro Studio, Maestro Cloud oraz integracja z EAS Build i GitHub Actions.
Nitro Modules to type-safe framework JSI dla React Native. Poznaj Nitrogen, model HybridObject, konfigurację Expo oraz benchmarki wydajności vs Turbo Modules.
Kompletny przewodnik po skracaniu cold startu w React Native 2026. Pomiar TTI w Instruments i Perfetto, Hermes V1, Nowa Architektura z Bridgeless Mode oraz cięcie bundla poniżej 4 MB, z konkretnymi patchami z produkcji.