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 อย่างไร
ติดตั้งและตั้งค่า QueryClient สำหรับ React Native
ใช้ useQuery ดึงข้อมูลจาก REST API
useMutation และ Optimistic Updates
Infinite Query สำหรับ Pagination ด้วย FlashList
Offline Persistence ด้วย MMKV
Invalidate, Refetch และ Query Keys
TanStack Query เทียบกับ SWR และ Apollo Client
ข้อผิดพลาดที่พบบ่อยและวิธีแก้
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>
);
}
คำเตือน: ใน v5 ชื่อ option หลายตัวเปลี่ยน เช่น cacheTime → gcTime, callbacks ทุกตัวรับ object เดียวแทน arguments หลายตัว ถ้าอัปเกรดจาก v4 ให้รัน npx @tanstack/query-codemod-v5 ก่อน
ใช้ 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'] });
},
});
}
เคล็ดลับ: เรียก cancelQueries ใน onMutate เสมอ. ถ้าไม่ทำ การ refetch ที่กำลังวิ่งอยู่จะ overwrite optimistic data ของคุณก่อนที่ mutation จะตอบกลับ
สำหรับ 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 ที่เราตั้งไว้ตอนต้น)
หมายเหตุ: ใช้ buster เปลี่ยนค่าเมื่อ shape ของ data เปลี่ยน เพื่อให้ผู้ใช้รุ่นเก่าโหลด cache schema เก่ามาแล้วแอปไม่ crash จากการอ่าน field ที่ไม่มี
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 v5 SWR 2 Apollo Client 3.10
โปรโตคอลที่รองรับ REST, GraphQL, gRPC (any Promise) REST, GraphQL (any Promise) GraphQL เป็นหลัก
Mutation API useMutation ครบ optimisticผ่าน mutate() ของ useSWR useMutation ครบ
Infinite/Pagination useInfiniteQuery built-inuseSWRInfinitefetchMore
Offline persistence Plugin AsyncStorage/MMKV ต้องเขียนเอง apollo3-cache-persist
Bundle size (gzip) ~13 KB ~4 KB ~33 KB
DevTools Flipper + standalone app SWR 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 ของผู้ใช้