คู่มือ TanStack Query (React Query) สำหรับ React Native 2026: Server State, Cache, Offline และ Pagination

คู่มือ TanStack Query v5 บน React Native ตั้งแต่ตั้งค่า QueryClient, useQuery, optimistic mutation, infinite scroll ด้วย FlashList ไปจนถึง offline persistence ด้วย MMKV พร้อมโค้ดที่รันได้จริง

TanStack Query React Native Guide 2026

อัปเดต: 5 มิถุนายน 2026

TanStack Query (เดิมชื่อ React Query) คือไลบรารีจัดการ server state สำหรับ React และ React Native ที่ช่วย fetch, cache, sync และ update ข้อมูลจาก API ให้กลายเป็นเรื่องง่าย โดยไม่ต้องเขียน reducer หรือ thunk แม้แต่บรรทัดเดียว ในฐานะคนที่ย้ายจาก React Web มาทำ React Native ผมพบว่า TanStack Query v5 ทำงานบนมือถือได้ดีกว่าที่คาดมาก เพราะตัวไลบรารีไม่ผูกกับ DOM และมี plugin สำหรับ AsyncStorage/MMKV เพื่อทำ offline cache โดยตรง บทความนี้จะพาคุณตั้งค่า TanStack Query v5 ใน React Native 0.77+ ตั้งแต่ QueryClient ไปจนถึง infinite scroll และ offline persistence พร้อมโค้ดที่รันได้จริง

  • TanStack Query v5.62+ รองรับ React 19 และ React Native 0.77 (New Architecture) อย่างเต็มรูปแบบ ใช้ API เดียวกับเวอร์ชันเว็บ 100%
  • ต้องตั้งค่า focusManager และ onlineManager ด้วย AppState และ NetInfo เพราะ default ของไลบรารีออกแบบมาสำหรับเบราว์เซอร์
  • ใช้ @tanstack/query-async-storage-persister คู่กับ MMKV เพื่อทำ offline-first cache ที่อ่านเร็วกว่า AsyncStorage ราว 30 เท่า
  • useInfiniteQuery + FlashList ของ Shopify เป็นชุดที่เร็วที่สุดสำหรับ pagination บนมือถือในปี 2026
  • Optimistic updates ผ่าน onMutate ลด perceived latency บน LTE/3G ได้อย่างมีนัยสำคัญ โดยไม่ต้องใช้ Redux
  • เลิกใช้ cacheTime ได้แล้ว, v5 เปลี่ยนชื่อเป็น gcTime และเปลี่ยน signature เป็น object-only

TanStack Query คืออะไร และต่างจาก Zustand/Redux อย่างไร

TanStack Query เป็น server state manager ไม่ใช่ client state manager แบบ Zustand หรือ Redux ความต่างนี้สำคัญมากในงาน React Native ที่เน็ตไม่เสถียร เพราะ server state มีคุณสมบัติพิเศษ คือข้อมูลล้าได้ (stale), ต้อง refetch เมื่อกลับมา foreground, ต้อง invalidate หลังเขียน, และต้อง share cache ระหว่างหลายหน้าจอ TanStack Query จัดการสิ่งเหล่านี้ให้ทั้งหมด ในขณะที่ Zustand จะให้คุณเก็บค่าใน store เฉย ๆ และคุณต้องเขียน sync logic เอง

ในแอปจริงที่ผมเคยส่ง ผมใช้ทั้งสองตัวร่วมกันเสมอ, TanStack Query สำหรับทุกอย่างที่มาจาก API และ Zustand สำหรับ UI state เช่น modal toggles, filter selections, theme คุณสามารถอ่านวิธีตั้งค่า Zustand แบบครบทุกฟีเจอร์ได้ในคู่มือ Zustand สำหรับ React Native ของเรา จุดที่นักพัฒนาเว็บมักเข้าใจผิดคือคิดว่า TanStack Query แทน Redux ได้ทั้งหมด (ไม่ใช่ครับ) มันแทนได้แค่ส่วนที่เป็น remote data fetching เท่านั้น

ฟีเจอร์เด่นที่ทำให้ผมเลือกใช้บน React Native ได้แก่ automatic background refetch, request deduplication (ถ้าหลาย component เรียก query เดียวกันใน 50ms จะ fetch แค่ครั้งเดียว), structural sharing (ลด re-render เมื่อข้อมูล shape เดิม), และ pluggable persistence layer ที่ต่อ MMKV ได้โดยตรง

ติดตั้งและตั้งค่า QueryClient สำหรับ React Native

เริ่มจากการติดตั้ง package ที่จำเป็น ผมแนะนำให้ใช้ @tanstack/react-query เวอร์ชัน 5.62 ขึ้นไปเพราะรองรับ React 19 และ Suspense API ใหม่ พร้อมกับ @react-native-community/netinfo สำหรับตรวจสถานะเครือข่าย ซึ่งเป็นสิ่งที่เว็บไม่ต้องสนใจแต่บนมือถือขาดไม่ได้

# ใช้ npm หรือ pnpm
npm install @tanstack/react-query @tanstack/query-async-storage-persister \
            @tanstack/react-query-persist-client \
            @react-native-community/netinfo react-native-mmkv

# สำหรับ Expo
npx expo install @tanstack/react-query @react-native-community/netinfo

ต่อไปคือส่วนที่นักพัฒนาเว็บมักลืม. TanStack Query เช็ค window.focus และ navigator.onLine เพื่อตัดสินใจว่าเมื่อไหร่ควร refetch บน React Native สอง API นี้ไม่มี เราจึงต้อง override focusManager และ onlineManager ด้วย AppState และ NetInfo เอง

// src/lib/queryClient.ts
import { QueryClient, focusManager, onlineManager } from '@tanstack/react-query';
import { AppState, Platform } from 'react-native';
import NetInfo from '@react-native-community/netinfo';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60,        // ข้อมูลถือว่า fresh 60 วินาที
      gcTime: 1000 * 60 * 10,      // เก็บ cache 10 นาทีหลังไม่มีคน subscribe
      retry: 2,
      refetchOnReconnect: 'always',
    },
    mutations: { retry: 1 },
  },
});

// 1) แทน window focus ด้วย AppState ของ RN
focusManager.setEventListener((handleFocus) => {
  const sub = AppState.addEventListener('change', (status) => {
    if (Platform.OS !== 'web') handleFocus(status === 'active');
  });
  return () => sub.remove();
});

// 2) แทน navigator.onLine ด้วย NetInfo
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected);
  });
});

จากนั้นห่อ root component ด้วย QueryClientProvider:

// App.tsx
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './src/lib/queryClient';

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

ใช้ useQuery ดึงข้อมูลจาก REST API

useQuery คือ hook ตัวเอกของไลบรารี รับ queryKey (array ที่ระบุ identity ของ query) และ queryFn (function ที่คืน Promise) แล้วคืน state object ที่บอก data, isPending, isError, error, refetch ฯลฯ จุดที่ผมชอบที่สุดคือ shape เดียวกันกับฝั่งเว็บเป๊ะ ทำให้โค้ดที่เคยเขียนใน Next.js ย้ายมาได้แทบจะ copy-paste

// src/features/products/useProducts.ts
import { useQuery } from '@tanstack/react-query';

type Product = { id: string; name: string; price: number };

async function fetchProducts(): Promise<Product[]> {
  const res = await fetch('https://api.example.com/products');
  if (!res.ok) throw new Error('Network error: ' + res.status);
  return res.json();
}

export function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
    staleTime: 1000 * 60 * 5,   // ข้อมูลสินค้าถือว่า fresh 5 นาที
  });
}

การใช้งานใน component:

// src/screens/ProductListScreen.tsx
import { ActivityIndicator, FlatList, Text, View } from 'react-native';
import { useProducts } from '../features/products/useProducts';

export function ProductListScreen() {
  const { data, isPending, isError, error, refetch, isRefetching } = useProducts();

  if (isPending) return <ActivityIndicator />;
  if (isError) return <Text>ผิดพลาด: {error.message}</Text>;

  return (
    <FlatList
      data={data}
      keyExtractor={(item) => item.id}
      refreshing={isRefetching}
      onRefresh={refetch}
      renderItem={({ item }) => (
        <View><Text>{item.name} / ฿{item.price}</Text></View>
      )}
    />
  );
}

สังเกตว่า pull-to-refresh ทำได้ด้วย prop refreshing และ onRefresh ที่ผูกกับ isRefetching และ refetch ของ TanStack Query โดยตรง โดยไม่ต้องสร้าง local state เอง รูปแบบนี้ใช้ได้กับทั้ง FlatList และ FlashList

useMutation และ Optimistic Updates

เมื่อต้อง POST/PUT/DELETE ใช้ useMutation สิ่งที่ทำให้ TanStack Query เหนือกว่า fetch เปล่า ๆ คือ optimistic update คืออัปเดต UI ทันทีตามที่ "เดา" ว่า server จะรับ แล้ว rollback ถ้าพลาด ผมใช้รูปแบบนี้กับปุ่ม like, follow, add-to-cart ทุกครั้ง เพราะ perceived latency บน 3G/LTE ลดจาก ~400ms เหลือ ~0ms

// src/features/products/useToggleFavorite.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useToggleFavorite() {
  const qc = useQueryClient();

  return useMutation({
    mutationFn: async (productId: string) => {
      const res = await fetch(`/api/favorites/${productId}`, { method: 'POST' });
      if (!res.ok) throw new Error('Toggle failed');
      return res.json();
    },

    // 1) ก่อน request, อัปเดต cache ทันที
    onMutate: async (productId) => {
      await qc.cancelQueries({ queryKey: ['favorites'] });
      const previous = qc.getQueryData<string[]>(['favorites']);

      qc.setQueryData<string[]>(['favorites'], (old = []) =>
        old.includes(productId)
          ? old.filter((id) => id !== productId)
          : [...old, productId]
      );

      return { previous };  // ส่งคืนเป็น context ให้ onError
    },

    // 2) ถ้าพลาด, rollback
    onError: (_err, _id, context) => {
      if (context?.previous) qc.setQueryData(['favorites'], context.previous);
    },

    // 3) ไม่ว่าจะสำเร็จหรือไม่, refetch ให้ตรงกับ server
    onSettled: () => {
      qc.invalidateQueries({ queryKey: ['favorites'] });
    },
  });
}

Infinite Query สำหรับ Pagination ด้วย FlashList

สำหรับ feed ที่เลื่อนได้ไม่สิ้นสุด useInfiniteQuery จัดการ cursor/offset ให้อัตโนมัติ ผมแนะนำให้ใช้คู่กับ FlashList ของ Shopify เพราะ recycler-based rendering ของมันเร็วกว่า FlatList อย่างเห็นได้ชัดบน list ยาว ๆ

// src/features/feed/useFeed.ts
import { useInfiniteQuery } from '@tanstack/react-query';

type Page = { items: Post[]; nextCursor: string | null };

export function useFeed() {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: async ({ pageParam }) => {
      const url = `/api/feed?cursor=${pageParam ?? ''}`;
      const res = await fetch(url);
      return res.json() as Promise<Page>;
    },
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    maxPages: 10,   // จำกัด memory บนเครื่องสเปคต่ำ
  });
}
// src/screens/FeedScreen.tsx
import { FlashList } from '@shopify/flash-list';
import { useFeed } from '../features/feed/useFeed';

export function FeedScreen() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeed();
  const items = data?.pages.flatMap((p) => p.items) ?? [];

  return (
    <FlashList
      data={items}
      estimatedItemSize={88}
      onEndReached={() => { if (hasNextPage) fetchNextPage(); }}
      onEndReachedThreshold={0.5}
      ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null}
      renderItem={({ item }) => <PostCard post={item} />}
    />
  );
}

ค่า maxPages: 10 เป็น option ที่มีเฉพาะใน v5 ที่จะทิ้งหน้าเก่า ๆ ออกจาก memory เมื่อ scroll ลึก ทำให้แอปไม่ค่อย ๆ บวมเมื่อผู้ใช้เลื่อนนานหลายชั่วโมง สิ่งนี้สำคัญมากบนอุปกรณ์ Android ระดับล่างที่มี RAM 3GB

Offline Persistence ด้วย MMKV

หัวใจของ offline-first app คือการ persist cache ให้คงอยู่หลังปิดแอป TanStack Query มี PersistQueryClientProvider ที่เสียบ storage layer ใดก็ได้ ผมเลือก MMKV เพราะเร็วกว่า AsyncStorage ~30 เท่าและรองรับ synchronous read ทำให้ rehydrate ที่ startup ไม่กระตุก หากคุณยังตัดสินใจไม่ได้ระหว่าง storage layer ลองอ่านคู่มือเปรียบเทียบ MMKV, AsyncStorage และ expo-sqlite ของเราก่อน

// src/lib/persister.ts
import { MMKV } from 'react-native-mmkv';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';

const storage = new MMKV({ id: 'tq-cache' });

const mmkvAsAsyncStorage = {
  setItem: (k: string, v: string) => { storage.set(k, v); return Promise.resolve(); },
  getItem: (k: string) => Promise.resolve(storage.getString(k) ?? null),
  removeItem: (k: string) => { storage.delete(k); return Promise.resolve(); },
};

export const persister = createAsyncStoragePersister({
  storage: mmkvAsAsyncStorage,
  key: 'TQ_CACHE_V1',
  throttleTime: 1000,
});
// App.tsx
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { persister } from './src/lib/persister';
import { queryClient } from './src/lib/queryClient';

export default function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister,
        maxAge: 1000 * 60 * 60 * 24 * 7,   // เก็บ cache 7 วัน
        buster: 'v1',                      // เปลี่ยน string นี้เพื่อล้าง cache เก่า
      }}
    >
      <RootNavigator />
    </PersistQueryClientProvider>
  );
}

หลังตั้งค่านี้ ถ้าผู้ใช้ปิดแอปแล้วเปิดใหม่โดยไม่มีเน็ต ข้อมูลล่าสุดยังโชว์อยู่ทันที (cache ถูก rehydrate จาก disk) และจะ refetch อัตโนมัติเมื่อ NetInfo รายงานว่าเครือข่ายกลับมา (ขอบคุณ onlineManager ที่เราตั้งไว้ตอนต้น)

Invalidate, Refetch และ Query Keys

วิธีออกแบบ query keys คือสิ่งที่แยกโค้ดที่ scale ได้กับโค้ดที่พังหลังสามเดือน หลักการที่ผมยึดมาตลอดคือใช้ array ที่เรียงจาก general ไป specific และยกขึ้นเป็นค่าคงที่ในไฟล์เดียว เพื่อหลีกเลี่ยง typo

// src/features/products/keys.ts
export const productKeys = {
  all: ['products'] as const,
  lists: () => [...productKeys.all, 'list'] as const,
  list: (filters: { category?: string }) => [...productKeys.lists(), filters] as const,
  details: () => [...productKeys.all, 'detail'] as const,
  detail: (id: string) => [...productKeys.details(), id] as const,
};

// Invalidate ทุก list หลัง mutation
qc.invalidateQueries({ queryKey: productKeys.lists() });

// Invalidate เฉพาะ list ที่กรองหมวด "shoes"
qc.invalidateQueries({ queryKey: productKeys.list({ category: 'shoes' }) });

// Invalidate detail ตัวเดียว
qc.invalidateQueries({ queryKey: productKeys.detail('p_42') });

เมื่อใช้ QueryClient.invalidateQueries โดยส่งเฉพาะ prefix queries ที่ key ขึ้นต้นเหมือนกันทั้งหมดจะถูก mark stale และ refetch อัตโนมัติถ้ามี component subscribe อยู่ ระบบ matching แบบ prefix นี้ทรงพลังมากและเป็นสาเหตุที่ผมหยุดเขียน thunk ไปเลย

TanStack Query เทียบกับ SWR และ Apollo Client

คำถามที่ผมเจอบ่อยที่สุดคือ "ในเมื่อมี SWR แล้วทำไมต้อง TanStack Query" คำตอบสั้น ๆ คือ ฟีเจอร์ที่ครบกว่าและการรองรับ mutation ที่จริงจังกว่า ตารางด้านล่างสรุปจุดต่างที่สำคัญสำหรับงาน React Native

ฟีเจอร์TanStack Query v5SWR 2Apollo Client 3.10
โปรโตคอลที่รองรับREST, GraphQL, gRPC (any Promise)REST, GraphQL (any Promise)GraphQL เป็นหลัก
Mutation APIuseMutation ครบ optimisticผ่าน mutate() ของ useSWRuseMutation ครบ
Infinite/PaginationuseInfiniteQuery built-inuseSWRInfinitefetchMore
Offline persistencePlugin AsyncStorage/MMKVต้องเขียนเองapollo3-cache-persist
Bundle size (gzip)~13 KB~4 KB~33 KB
DevToolsFlipper + standalone appSWR DevTools (เว็บเท่านั้น)Apollo DevTools
เหมาะกับแอปกลาง–ใหญ่ที่ผสม REST+GraphQLโปรเจ็กต์เล็กที่ต้องการ minimalแอปที่เป็น GraphQL ล้วน

ในแอปที่ผมเคย ship ส่วนใหญ่ใช้ TanStack Query เพราะมันยืดหยุ่นกับ backend ทุกแบบ คือสลับจาก REST ไป GraphQL หรือ tRPC ได้โดยไม่ต้องเปลี่ยน hook layer หลักการของมันคือ "ส่ง Promise มาให้ฉัน แล้วฉันจัดการที่เหลือ"

ข้อผิดพลาดที่พบบ่อยและวิธีแก้

1. Query refetch ทุกครั้งที่กลับมาจาก background

ค่า default ของ refetchOnWindowFocus คือ true ซึ่งบนมือถือแปลว่า refetch ทุกครั้งที่ user สลับแอป ถ้า API คุณคิดเงินตามจำนวนเรียก ให้ตั้ง staleTime อย่างน้อย 30 วินาทีและพิจารณาปิด refetchOnWindowFocus สำหรับ query ที่หนัก

2. ข้อมูลไม่ persist หลังปิดแอป

เช็คก่อนว่าคุณห่อด้วย PersistQueryClientProvider ไม่ใช่ QueryClientProvider เปล่า ๆ ถ้ายังไม่ persist ลองดู throttleTime. ถ้าตั้งสูงเกินและแอปถูกฆ่าก่อน 1 วินาทีจะเขียนลง disk ไม่ทัน ลดเหลือ 200ms สำหรับ debug

3. TypeScript บ่นว่า data เป็น undefined

นี่คือ correct behavior ของ v5 ที่ data เป็น undefined จนกว่า query แรกจะสำเร็จ ใช้ isPending guard ก่อน หรือกำหนด initialData หรือ placeholderData ถ้ามีค่า default ที่สมเหตุสมผล

4. Memory ค่อย ๆ บวมเมื่อใช้ infinite query นาน ๆ

ตั้ง maxPages ใน useInfiniteQuery เพื่อให้หน้าที่เก่าที่สุดถูกลบเมื่อเลื่อนเกินจำนวนที่กำหนด สำหรับแอปที่ผู้ใช้ scroll ลึกมาก (เช่น social feed) ค่าระหว่าง 5–15 มักลงตัว

หากแอปคุณกำลังย้ายไป New Architecture ของ React Native (Fabric/TurboModules) TanStack Query ทำงานได้ปกติเพราะไม่ผูกกับ Native module ใด ๆ แต่ก็ควรอ่านคู่มือ New Architecture ก่อนเพื่อตรวจ dependencies อื่นในโปรเจ็กต์ คุณสามารถอ้างอิงรายละเอียดเชิงลึกของ API ทั้งหมดได้จากเอกสารทางการของ TanStack Query และติดตาม changelog ผ่านหน้า releases บน GitHub

คำถามที่พบบ่อย

TanStack Query ใช้กับ React Native ได้จริงหรือไม่?

ได้ครับ ใช้ได้เต็มที่ตั้งแต่ v3 เป็นต้นมา และในเวอร์ชัน v5 รองรับ React Native 0.77 พร้อม New Architecture อย่างเป็นทางการ ต้อง override focusManager และ onlineManager ด้วย AppState และ NetInfo เพราะค่า default ออกแบบมาสำหรับเบราว์เซอร์

TanStack Query แทน Redux ได้ทั้งหมดหรือไม่?

แทนได้เฉพาะส่วนที่เป็น server state (ข้อมูลจาก API) ส่วน client state เช่น UI toggles, form state, theme ยังต้องใช้ Zustand, Jotai หรือ useState รูปแบบที่ดีคือใช้ TanStack Query สำหรับ remote data และ Zustand สำหรับ local UI state

ทำให้ TanStack Query ทำงาน offline ได้อย่างไร?

ใช้ @tanstack/react-query-persist-client คู่กับ storage adapter (AsyncStorage หรือ MMKV) เพื่อ persist cache ลง disk จากนั้นห่อ app ด้วย PersistQueryClientProvider เมื่อเปิดแอปครั้งหน้า cache จะถูก rehydrate และ TanStack Query จะ refetch ใหม่อัตโนมัติเมื่อเครือข่ายกลับมา

ต่างจาก SWR อย่างไร เลือกตัวไหนดี?

TanStack Query มี mutation API ที่จริงจังกว่า รองรับ infinite query และ offline persistence built-in ส่วน SWR เล็กกว่า (~4 KB เทียบกับ ~13 KB) แต่ฟีเจอร์น้อยกว่า ถ้าแอปคุณมี mutation มากหรือต้องการ offline support ให้เลือก TanStack Query

ค่า staleTime ที่เหมาะสมสำหรับแอปมือถือคือเท่าไหร่?

ขึ้นกับลักษณะข้อมูล. สำหรับ user profile, settings ใช้ 5–10 นาที สำหรับ feed/timeline ใช้ 30–60 วินาที สำหรับข้อมูล real-time เช่น chat ใช้ 0 (always stale) คู่กับ refetch interval หลักคืออย่าให้ต่ำเกินไปบนแอปมือถือเพราะจะกิน battery และ data plan ของผู้ใช้

Anita Iyer
เกี่ยวกับผู้เขียน Anita Iyer

Cross-platform mobile developer who came to RN from web. Bridges the two worlds and explains the seams.