Ứng dụng báo giá rèm cửa tôi đang làm có một yêu cầu không thể thương lượng: mất sóng vẫn phải chạy được. Báo giá được đo đạc và lập ngay trong lúc đi khảo sát tận nơi, mà khảo sát thì toàn ở nhà mới xây hoặc dưới tầng hầm, chỗ sóng điện thoại chỉ có trong lời đồn. Chính ràng buộc này buộc tôi phải trả lời nghiêm túc một câu hỏi mà phần lớn dự án React thường cho qua loa: loại state nào thì nên để ở đâu?
Sai lầm hay gặp nhất là dồn mọi thứ vào một store. Cache API, tuỳ chọn của người dùng, state của form, auth token, tất cả nhét chung vào một cục Redux. Nhưng đây là những loại state khác nhau, có vòng đời khác nhau, và mỗi loại hợp với một công cụ riêng. Zustand, React Query và AsyncStorage, mỗi cái giải quyết tốt đúng một trong các bài toán đó. Chỉ cần vạch rõ ranh giới giữa chúng là kiến trúc phần lớn sẽ tự vào khuôn.
Ba loại state#
Trước khi tranh luận nên dùng library nào, hãy gọi cho đúng tên thứ mình đang quản lý đã.
Client state chỉ tồn tại khi ứng dụng đang chạy: các nút bật/tắt trên giao diện, tab đang chọn, dữ liệu người dùng nhập vào form, modal đang mở hay đóng. Không có ai ở phía máy chủ sở hữu nó, và process tắt thì nó cũng mất theo.
Server state thì khác. Máy chủ mới là chủ sở hữu, ứng dụng của bạn chỉ giữ một bản sao ở local. Ví dụ như hồ sơ người dùng, danh sách sản phẩm, thông báo, dữ liệu feed. Bản sao của bạn sẽ dần cũ đi, cần được fetch lại, và ngay lúc này rất có thể một client khác đang thấy một phiên bản khác của cùng dữ liệu đó.
Persistent state là những gì phải còn nguyên sau khi khởi động lại ứng dụng: auth token, cờ đánh dấu người dùng đã xong onboarding, các tuỳ chọn đã lưu, dữ liệu dùng khi offline. Loại state này nằm ngay trên thiết bị.
Ứng dụng nào cũng có đủ cả ba loại. Và phần lớn mớ bòng bong đều bắt nguồn từ việc dùng một công cụ để xử lý cả ba.
Zustand: client state#
Zustand là một library quản lý state nhỏ gọn cho React, không có mấy thủ tục rườm rà. Không cần provider, không boilerplate, không phải bọc năm lớp context chồng lên nhau. Bạn tạo một store, dùng nó trong component, vậy là xong bài học.
Nó là lựa chọn đúng khi nhiều component cần dùng chung một UI state (sidebar đang mở hay đóng, bộ lọc đang bật, các mục đang chọn), khi state ở cấp ứng dụng không đến từ API (theme, ngôn ngữ, feature flag đọc lúc khởi động), hoặc khi có logic phía client thật sự cần quản lý, như tính tiền giỏ hàng hay một wizard nhiều bước. Nói chung là những thứ cần cập nhật đồng bộ và dễ đoán.
Một store cơ bản trông như thế này:
import { create } from 'zustand'
interface AppState {
theme: 'light' | 'dark'
sidebarOpen: boolean
selectedFilters: string[]
setTheme: (theme: 'light' | 'dark') => void
toggleSidebar: () => void
setFilters: (filters: string[]) => void
}
const useAppStore = create<AppState>((set) => ({
theme: 'light',
sidebarOpen: false,
selectedFilters: [],
setTheme: (theme) => set({ theme }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setFilters: (filters) => set({ selectedFilters: filters }),
}))Còn đây là cách dùng trong component:
function Sidebar() {
const { sidebarOpen, toggleSidebar } = useAppStore()
if (!sidebarOpen) return null
return (
<div className="sidebar">
<button onClick={toggleSidebar}>Close</button>
{/* sidebar content */}
</div>
)
}Những thứ tôi không bỏ vào Zustand#
Chủ yếu là response từ API. Tôi từng gặp nhiều dự án mà lời gọi API nào cũng ghi kết quả vào một store Zustand, còn component thì đọc từ store thay vì gọi query. Rốt cuộc bạn phải tự tay viết lại toàn bộ phần cache invalidation, trạng thái loading, xử lý lỗi, refetch và phân trang, trong khi đó lại đúng là những việc React Query sinh ra để làm thay bạn.
Thứ hai là bất cứ thứ gì phải còn lại sau khi khởi động lại. State của Zustand nằm trong bộ nhớ, đóng ứng dụng là mất. Middleware persist có thể nối Zustand với AsyncStorage (phần sau sẽ nói kỹ hơn), nhưng dùng nó phải là một quyết định có cân nhắc, đừng để thành thói quen.
React Query: server state#
React Query (giờ đã đổi tên thành TanStack Query) quản lý toàn bộ vòng đời của dữ liệu từ xa: fetch, cache, đồng bộ, cập nhật và dọn rác. Điểm cần đổi tư duy là nó xem dữ liệu từ máy chủ như một bộ cache cần giữ cho mới, chứ không phải state của riêng bạn. Máy chủ mới là chủ. Bạn chỉ đang cầm một bản sao.
Tôi dùng nó cho mọi thứ lấy từ API. Chẳng hạn dữ liệu mà nhiều component cùng cần từ một endpoint (các request trùng nhau được tự động gộp lại), danh sách phân trang hoặc cuộn vô hạn, dữ liệu cần fetch lại ngầm khi người dùng quay lại ứng dụng, và optimistic update, tức là giao diện đổi ngay lập tức rồi rollback nếu máy chủ từ chối.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
})
}
function useUpdateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: { id: string; name: string }) =>
fetch(`/api/users/${data.id}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['user', variables.id] })
},
})
}Dùng trong component:
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId)
const updateUser = useUpdateUser()
if (isLoading) return <Spinner />
if (error) return <ErrorMessage error={error} />
return (
<div>
<h1>{user.name}</h1>
<button
onClick={() => updateUser.mutate({ id: userId, name: 'New Name' })}
disabled={updateUser.isPending}
>
Update Name
</button>
</div>
)
}Những thứ có sẵn mà bạn không phải tự viết#
Tôi kiên quyết để dữ liệu từ máy chủ ở đây vì số cơ chế được tặng kèm miễn phí nhiều đến choáng ngợp. Năm component cùng lấy một user thì chỉ tạo ra đúng một network request. Dữ liệu tự fetch lại khi cửa sổ được focus trở lại hoặc khi có mạng lại. Cache cũ tự được dọn. Query nào cũng trả sẵn isLoading, isError, data cùng các trường liên quan, nên giao diện cho trạng thái loading và lỗi không còn phải làm thủ công từng chỗ. Request thất bại được retry với exponential backoff. Còn optimistic update kèm rollback là pattern có sẵn, chứ không phải mất nguyên buổi chiều tự đi dây.
Những việc không nên dùng React Query#
Client state thuần tuý. Nếu dữ liệu không bao giờ đi qua máy chủ thì React Query chỉ thêm rườm rà. Trạng thái mở/đóng của một modal đâu cần cache invalidation hay refetch định kỳ.
React Query cũng không phải tầng lưu trữ lâu dài. Cache nằm trong bộ nhớ, nên mỗi lần khởi động lại là cache trống trơn và phải fetch lại một loạt. Bạn có thể persist cache (trên React Native thì nên làm, xem phần offline bên dưới), nhưng source of truth lúc nào cũng là máy chủ.
AsyncStorage: những gì cần giữ lại sau khi khởi động lại#
AsyncStorage là kho lưu trữ key-value của React Native. Trên web, thứ tương đương là localStorage (đồng bộ) hoặc IndexedDB (bất đồng bộ, làm được nhiều việc hơn). Dù dùng cái nào thì ý tưởng cũng như nhau: dữ liệu được ghi xuống thiết bị nên vẫn còn sau khi process kết thúc.
Bên dưới mỗi nền tảng thực chất là gì#
Cùng một API, nhưng bên dưới là ba backend khá khác nhau (giả sử bạn dùng @react-native-async-storage/async-storage).
Trên Android, bên dưới là SQLite, thông qua RKStorage, lưu trong một cơ sở dữ liệu nằm ở thư mục lưu trữ nội bộ của ứng dụng. Nhanh, ổn định, lại được sandbox nên ứng dụng khác không đụng vào được. Lưu ý giới hạn dung lượng mặc định trong bảng bên dưới.
Trên iOS, giá trị nhỏ được lưu bằng NSUserDefaults, còn giá trị lớn hơn thì được serialise ra tệp, cũng nằm trong sandbox của ứng dụng. Apple không đặt giới hạn cứng cho NSUserDefaults, nhưng thông lệ hợp lý là giữ mỗi giá trị dưới vài trăm KB. Nếu dữ liệu của bạn lớn hơn thế thì có lẽ bạn nên dùng hẳn một cơ sở dữ liệu đàng hoàng như WatermelonDB hay Realm.
Trên web (React Native Web hoặc Expo Web), AsyncStorage chuyển sang dùng localStorage, dung lượng tối đa khoảng 5-10 MB tuỳ trình duyệt. Ứng dụng React chỉ chạy trên web thì có thể dùng thẳng localStorage, hoặc dùng IndexedDB qua một wrapper như idb-keyval nếu dữ liệu lớn hơn.
| Nền tảng | Backend | Giới hạn dung lượng | Vị trí |
|---|---|---|---|
| Android | SQLite (RKStorage) | Mặc định ~6 MB (chỉnh được) | Bộ nhớ nội bộ của ứng dụng |
| iOS | NSUserDefaults / tệp | Không có giới hạn cứng (nên giữ giá trị nhỏ) | Sandbox của ứng dụng |
| Web | localStorage | ~5-10 MB (tuỳ trình duyệt) | Bộ nhớ theo origin của trình duyệt |
Tôi lưu gì trong đó#
Auth token và dữ liệu phiên đăng nhập, các tuỳ chọn cần giữ lại (ngôn ngữ, theme, cài đặt thông báo), cờ đã xong onboarding, dữ liệu cache để dùng khi offline. Tóm lại là những mẩu dữ liệu key-value nho nhỏ cần giữ lại sau khi khởi động lại, chỉ vậy thôi.
API của nó đơn giản hết mức mà một kho lưu trữ có thể có:
import AsyncStorage from '@react-native-async-storage/async-storage'
// Store a value
await AsyncStorage.setItem('auth_token', token)
// Read a value
const token = await AsyncStorage.getItem('auth_token')
// Store an object (must serialize)
await AsyncStorage.setItem('user_preferences', JSON.stringify({
theme: 'dark',
language: 'en',
notifications: true,
}))
// Read an object
const prefs = JSON.parse(await AsyncStorage.getItem('user_preferences') ?? '{}')
// Remove a value
await AsyncStorage.removeItem('auth_token')
// Clear everything (careful with this)
await AsyncStorage.clear()Phiên bản cho web#
Ứng dụng React chỉ chạy trên web thì không cần đến AsyncStorage. Các trường hợp đơn giản dùng localStorage là đủ:
// Synchronous - blocks the main thread, but fine for small data
localStorage.setItem('theme', 'dark')
const theme = localStorage.getItem('theme')
// For structured data
localStorage.setItem('user', JSON.stringify({ name: 'Jared', role: 'admin' }))
const user = JSON.parse(localStorage.getItem('user') ?? '{}')Còn dữ liệu lớn hơn thì dùng IndexedDB:
import { get, set, del } from 'idb-keyval'
await set('large-dataset', hugeArray)
const data = await get('large-dataset')
await del('large-dataset')Những việc không nên dùng AsyncStorage#
AsyncStorage là kho key-value, không phải cơ sở dữ liệu. Dữ liệu quan hệ, mảng hàng nghìn phần tử, bất cứ thứ gì cần đánh index hay query thì hãy để cho SQLite (qua expo-sqlite), WatermelonDB hoặc Realm.
Nó cũng không phải kho lưu trữ bảo mật. Trên thiết bị đã root hoặc jailbreak, ai cũng đọc được nội dung AsyncStorage. Token nhạy cảm phải để trong expo-secure-store hoặc react-native-keychain.
Kết hợp cả ba với nhau#
Ứng dụng thực tế thường dùng cả ba cùng lúc. Dưới đây là vài pattern tôi dùng thường xuyên.
Luồng đăng nhập#
// 1. AsyncStorage: persist the auth token
import AsyncStorage from '@react-native-async-storage/async-storage'
async function saveToken(token: string) {
await AsyncStorage.setItem('auth_token', token)
}
async function getToken(): Promise<string | null> {
return AsyncStorage.getItem('auth_token')
}
// 2. Zustand: track auth state in memory
import { create } from 'zustand'
interface AuthState {
isAuthenticated: boolean
token: string | null
setAuth: (token: string) => void
clearAuth: () => void
}
const useAuthStore = create<AuthState>((set) => ({
isAuthenticated: false,
token: null,
setAuth: (token) => set({ isAuthenticated: true, token }),
clearAuth: () => set({ isAuthenticated: false, token: null }),
}))
// 3. React Query: fetch user profile using the token
function useCurrentUser() {
const token = useAuthStore((s) => s.token)
return useQuery({
queryKey: ['currentUser'],
queryFn: () =>
fetch('/api/me', {
headers: { Authorization: `Bearer ${token}` },
}).then(res => res.json()),
enabled: !!token, // Only fetch when we have a token
})
}Còn lúc ứng dụng khởi động:
// App initialization
async function initializeApp() {
const token = await getToken() // Read from AsyncStorage
if (token) {
useAuthStore.getState().setAuth(token) // Put in Zustand for quick access
// React Query will automatically fetch the user profile
}
}AsyncStorage giữ token qua các lần khởi động lại, Zustand giúp đọc token ở bất cứ đâu mà không tốn kém, còn React Query fetch hồ sơ người dùng ngay khi có token. Mỗi công cụ chỉ làm đúng một việc mà nó giỏi nhất.
Theme vẫn giữ nguyên sau khi khởi động lại#
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
// Zustand with persistence middleware bridges the gap
const useThemeStore = create(
persist(
(set) => ({
theme: 'light' as 'light' | 'dark',
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
}),
{
name: 'theme-storage',
storage: createJSONStorage(() => AsyncStorage), // React Native
// storage: createJSONStorage(() => localStorage), // Web
}
)
)Zustand quản lý state trong lúc ứng dụng chạy, còn middleware persist âm thầm đồng bộ state đó xuống AsyncStorage (hoặc localStorage trên web). Component của bạn không biết, mà cũng chẳng cần biết, là có một tầng lưu trữ nằm bên dưới.
Đọc dữ liệu theo kiểu offline-first#
import { useQuery } from '@tanstack/react-query'
import AsyncStorage from '@react-native-async-storage/async-storage'
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: async () => {
try {
const res = await fetch('/api/products')
const data = await res.json()
// Cache in AsyncStorage for offline use
await AsyncStorage.setItem('cached_products', JSON.stringify(data))
return data
} catch (error) {
// Network failed - try cached data
const cached = await AsyncStorage.getItem('cached_products')
if (cached) return JSON.parse(cached)
throw error
}
},
staleTime: 10 * 60 * 1000,
})
}React Query lo việc fetch và cache trong bộ nhớ, còn AsyncStorage là phương án dự phòng khi mất mạng.
Làm chế độ offline, từng bước một#
Đây mới là phần tôi thật sự quan tâm. Người ta hay nói người dùng mở ứng dụng trên tàu điện ngầm, trong thang máy, trên máy bay, và đúng là vậy. Nhưng trường hợp của tôi thì yêu cầu thẳng thừng hơn nhiều: báo giá được lập ngay tại công trình, và tôi không thể mặc định là công trình có sóng. Một cái spinner quay mãi dưới tầng hầm đồng nghĩa với mất một đơn báo giá.
Điều bất ngờ dễ chịu là chỉ với ba library này, bạn đã dựng được một hệ thống offline thật sự vững vàng mà không cần đến framework cồng kềnh nào.
Bước 1: biết lúc nào đang offline#
Thiết bị cần báo cho ứng dụng biết tình trạng mạng, và ứng dụng cần một chỗ để lưu thông tin đó. @react-native-community/netinfo cung cấp các event, còn một store Zustand bé xíu giúp đọc được trạng thái đó từ bất cứ đâu.
import { create } from 'zustand'
import NetInfo from '@react-native-community/netinfo'
interface NetworkState {
isOnline: boolean
setOnline: (online: boolean) => void
}
const useNetworkStore = create<NetworkState>((set) => ({
isOnline: true,
setOnline: (online) => set({ isOnline: online }),
}))
// Subscribe once at app startup
NetInfo.addEventListener((state) => {
useNetworkStore.getState().setOnline(state.isConnected ?? false)
})Từ giờ, component nào cũng có thể gọi useNetworkStore((s) => s.isOnline) để hiện banner báo offline, vô hiệu hoá nút gửi, hoặc báo cho người dùng biết thay đổi sẽ được đồng bộ sau.
Bước 2: cấu hình React Query cho chế độ offline#
React Query có sẵn cơ chế xử lý offline thông qua networkMode, quyết định query và mutation sẽ làm gì khi mất mạng.
import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst',
// Return cached data immediately, then refetch in background when online
staleTime: 5 * 60 * 1000,
gcTime: 24 * 60 * 60 * 1000, // Keep cache for 24 hours
retry: (failureCount, error) => {
// Don't retry if we're offline - it'll just fail again
if (!useNetworkStore.getState().isOnline) return false
return failureCount < 3
},
},
mutations: {
networkMode: 'offlineFirst',
},
},
})Có ba chế độ:
| Chế độ | Hành vi |
|---|---|
online (mặc định) | Query chỉ chạy khi có mạng. Mất mạng thì tạm dừng. |
always | Query chạy bất kể có mạng hay không. queryFn của bạn phải tự xử lý lỗi. |
offlineFirst | Query chạy một lần (để lấy dữ liệu cache), rồi tạm dừng, đợi có mạng mới fetch lại. |
Với phần lớn ứng dụng di động, offlineFirst là lựa chọn phù hợp: dữ liệu cache hiện ra ngay, còn dữ liệu mới sẽ về khi có mạng.
Bước 3: persist query cache#
Mặc định, cache chỉ nằm trong bộ nhớ, nên mỗi lần khởi động lại là spinner quay khắp màn hình. Muốn có chế độ offline thì bạn persist nó xuống AsyncStorage:
import { QueryClient } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import AsyncStorage from '@react-native-async-storage/async-storage'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 24 * 60 * 60 * 1000, // 24 hours - must be >= maxAge
},
},
})
const asyncStoragePersister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'react-query-cache',
})
// In your App component
function App() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: asyncStoragePersister,
maxAge: 24 * 60 * 60 * 1000, // Don't restore data older than 24 hours
dehydrateOptions: {
shouldDehydrateQuery: (query) => {
// Only persist successful queries
return query.state.status === 'success'
},
},
}}
>
<YourApp />
</PersistQueryClientProvider>
)
}Giờ thì dù mở ứng dụng ở chỗ không có sóng, người dùng vẫn thấy ngay dữ liệu fetch lần gần nhất thay vì một màn hình trắng, và khi có mạng lại thì React Query lặng lẽ fetch dữ liệu mới.
Bước 4: xếp hàng các thao tác ghi khi offline#
Đọc là phần dễ. Ghi mới là chỗ offline trở nên thú vị: người dùng thêm bình luận, gửi đơn hàng hay sửa hồ sơ lúc không có mạng, và thay đổi đó phải được xếp vào hàng đợi để gửi lại sau.
React Query giải quyết chuyện này bằng useMutation kèm optimistic update trong onMutate:
import { useMutation, useQueryClient } from '@tanstack/react-query'
import AsyncStorage from '@react-native-async-storage/async-storage'
interface Comment {
id: string
text: string
postId: string
createdAt: string
pending?: boolean
}
function useAddComment(postId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (text: string) => {
const res = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
return res.json()
},
// Optimistic update - show the comment immediately
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['comments', postId] })
const previous = queryClient.getQueryData<Comment[]>(['comments', postId])
const optimisticComment: Comment = {
id: `temp-${Date.now()}`,
text,
postId,
createdAt: new Date().toISOString(),
pending: true, // Show a "sending..." indicator in the UI
}
queryClient.setQueryData<Comment[]>(
['comments', postId],
(old) => [...(old ?? []), optimisticComment]
)
return { previous }
},
// Roll back on failure
onError: (err, text, context) => {
if (context?.previous) {
queryClient.setQueryData(['comments', postId], context.previous)
}
},
// Refetch to get the real data from the server
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] })
},
})
}Khi đặt networkMode: 'offlineFirst' cho mutation, mutationFn sẽ tạm dừng cho tới khi có mạng trở lại. Optimistic update đưa bình luận lên màn hình ngay lập tức, còn lời gọi API thật sẽ chạy khi kết nối lại.
Nếu cần một hàng đợi nghiêm túc hơn, chẳng hạn có vài chục thay đổi đang chờ và phải gửi lại đúng thứ tự, bạn có thể persist luôn cả hàng đợi mutation:
import { MutationCache } from '@tanstack/react-query'
// Save pending mutations to AsyncStorage
const mutationCache = new MutationCache({
onError: async (error, variables, context, mutation) => {
// Log failed mutations for debugging
const pending = JSON.parse(
await AsyncStorage.getItem('pending_mutations') ?? '[]'
)
pending.push({
key: mutation.options.mutationKey,
variables,
timestamp: Date.now(),
})
await AsyncStorage.setItem('pending_mutations', JSON.stringify(pending))
},
})Bước 5: đồng bộ lại khi có mạng#
Phần lớn việc xử lý khi kết nối lại thì React Query tự lo: mutation đang tạm dừng sẽ chạy tiếp, query đã cũ sẽ được fetch lại. Bạn chỉ cần nối các manager của nó với event của React Native.
import NetInfo from '@react-native-community/netinfo'
import { onlineManager, focusManager } from '@tanstack/react-query'
import { AppState } from 'react-native'
// Tell React Query about network state changes
onlineManager.setEventListener((setOnline) => {
return NetInfo.addEventListener((state) => {
setOnline(!!state.isConnected)
})
})
// Refetch when the app comes back to foreground
focusManager.setEventListener((setFocused) => {
const subscription = AppState.addEventListener('change', (status) => {
setFocused(status === 'active')
})
return () => subscription.remove()
})Khi người dùng để ứng dụng chạy nền một tiếng rồi mở lại, focusManager sẽ kích hoạt refetch để họ thấy dữ liệu mới. Khi họ ra khỏi đường hầm và có sóng trở lại, onlineManager cho những thao tác đang tạm dừng chạy tiếp.
Bước 6: thông báo rõ trên giao diện#
Dù làm gì thì cũng đừng lẳng lặng nuốt lỗi mạng. Hãy cho người dùng biết họ đang offline và thay đổi nào vẫn đang chờ gửi.
import { useNetworkStore } from './stores/network'
function OfflineBanner() {
const isOnline = useNetworkStore((s) => s.isOnline)
if (isOnline) return null
return (
<View style={styles.banner}>
<Text>You're offline. Changes will sync when you reconnect.</Text>
</View>
)
}
function CommentItem({ comment }: { comment: Comment }) {
return (
<View style={[styles.comment, comment.pending && styles.pending]}>
<Text>{comment.text}</Text>
{comment.pending && (
<Text style={styles.pendingLabel}>Sending...</Text>
)}
</View>
)
}Bức tranh toàn cảnh#
┌─────────────────────────────────────────────────┐
│ Components │
│ useQuery() for reads useMutation() for writes│
└──────────┬──────────────────────┬────────────────┘
│ │
┌─────▼──────┐ ┌─────▼──────┐
│ React Query │ │ React Query │
│ Cache │ │ Mutation │
│ (in-memory) │ │ Queue │
└─────┬──────┘ └─────┬──────┘
│ │
┌─────▼──────────────────────▼──────┐
│ AsyncStorage Persister │
│ (survives app restart) │
└─────┬──────────────────────┬──────┘
│ │
┌─────▼──────┐ ┌─────▼──────┐
│ Zustand │ │ Network │
│ (isOnline, │◄───────│ NetInfo │
│ UI state) │ │ │
└────────────┘ └────────────┘| Lớp | Công cụ | Vai trò |
|---|---|---|
| Phát hiện mạng | Zustand + NetInfo | Theo dõi online/offline, điều khiển banner trên giao diện |
| Fetch dữ liệu | React Query | Fetch khi có mạng, trả dữ liệu cache khi offline |
| Persist cache | React Query + AsyncStorage | Khôi phục cache khi khởi động lại ứng dụng |
| Ghi khi offline | Mutation của React Query | Xếp hàng mutation, gửi lại khi có mạng |
| Optimistic UI | onMutate của React Query | Hiện thay đổi ngay, rollback nếu thất bại |
| Đồng bộ khi ứng dụng được focus | focusManager của React Query | Fetch lại dữ liệu cũ khi ứng dụng quay lại foreground |
Khi nào cần một sync engine thực thụ#
Tất cả những gì ở trên đều giả định máy chủ là source of truth và offline chỉ là tạm thời. Nếu bạn cần offline-first thật sự, có cả giải quyết xung đột (hình dung một ứng dụng ghi chú mà hai thiết bị cùng sửa một tài liệu lúc offline), thì stack này không kham nổi, và bạn cần một sync engine đúng nghĩa. WatermelonDB được làm riêng cho React Native, chạy trên SQLite và có sẵn giao thức sync để giải quyết xung đột. Realm kết hợp Atlas Device Sync là một cơ sở dữ liệu offline-first đầy đủ, tự động giải quyết xung đột qua MongoDB Atlas. PowerSync là một tầng sync dựa trên SQLite, chạy được với backend Postgres sẵn có của bạn. Còn nếu muốn toàn quyền kiểm soát thì bạn luôn có thể dùng Expo SQLite và tự viết logic sync.
Bộ ba Zustand + React Query + AsyncStorage đáp ứng được khoảng 80% ứng dụng di động. 20% còn lại, như các trình soạn thảo cộng tác hay những quy trình làm việc offline nặng trên nhiều thiết bị, thì cần một sync database chuyên dụng.
Một mẩu state nên để ở đâu?#
Khi phân vân không biết một thứ nên để ở đâu, tôi lần lượt hỏi mấy câu sau:
| Câu hỏi | Nếu có → dùng |
|---|---|
| Có đến từ máy chủ/API không? | React Query |
| Có phải UI state chỉ ở phía client và được nhiều component dùng chung không? | Zustand |
| Có cần giữ lại sau khi khởi động lại ứng dụng không? | AsyncStorage (+ Zustand persist nếu cần) |
| Có nhạy cảm không (token, mật khẩu)? | expo-secure-store / react-native-keychain |
| Có phải dữ liệu có cấu trúc, dung lượng lớn và cần query không? | SQLite / WatermelonDB / Realm |
| Có phải một input form đơn giản, chỉ một component dùng không? | useState |
Các trường hợp thường gặp#
| State | Công cụ | Lý do |
|---|---|---|
| Dữ liệu response từ API | React Query | Cache, gộp request trùng, refetch, trạng thái loading |
| Tab đang chọn / bộ lọc đang bật | Zustand | Chỉ ở client, nhiều component cần dùng |
| Auth token | AsyncStorage + Zustand | Giữ qua các lần khởi động lại, đọc nhanh từ bộ nhớ |
| Tuỳ chọn theme | Zustand với middleware persist | Client state cần giữ lại sau khi khởi động lại |
| Giỏ hàng | Zustand với middleware persist | Logic phía client phức tạp, cần giữ lại sau khi khởi động lại |
| Input form | useState | Chỉ một component, không cần chia sẻ |
| Hồ sơ người dùng từ API | React Query | Server state, có thể đã cũ |
| Cờ đã xong onboarding | AsyncStorage | Chỉ là một giá trị boolean cần lưu lại |
| Feed được cache để xem offline | React Query + AsyncStorage | Fetch từ máy chủ, lỗi thì quay về dùng cache |
Cài đặt theo từng nền tảng#
React Native (Android + iOS)#
Cài cả ba một lượt:
npm install zustand @tanstack/react-query @react-native-async-storage/async-storageThêm kho lưu trữ bảo mật cho những dữ liệu nhạy cảm:
npx expo install expo-secure-store
# or
npm install react-native-keychainExpo#
Với Expo, AsyncStorage chạy được ngay, không cần native linking.
npx expo install @react-native-async-storage/async-storageReact chỉ chạy trên web#
Bỏ hẳn AsyncStorage, dùng thẳng localStorage hoặc IndexedDB.
npm install zustand @tanstack/react-query
# Optional for IndexedDB
npm install idb-keyvalMiddleware persist của Zustand vốn đã dùng localStorage làm mặc định trên web:
persist(storeConfig, {
name: 'my-store',
// localStorage is the default on web - no extra config needed
})Những lỗi tôi gặp đi gặp lại#
Bỏ dữ liệu API vào Zustand. Nếu trong action của bạn có dòng setUsers(apiResponse.users) thì dừng lại. Đó là việc của React Query, và chỉ một sprint nữa thôi là bạn sẽ tự chế lại cache invalidation, mà còn chế dở.
Dùng React Query cho client state. Nếu queryFn không hề gửi network request nào thì bạn đang dùng sai công cụ.
Dùng AsyncStorage như cơ sở dữ liệu. Serialise một mảng 10,000 phần tử vào kho key-value thì kết cục chỉ có khóc. Hãy dùng SQLite hoặc một cơ sở dữ liệu thật sự.
Lưu token mà không mã hoá. AsyncStorage không phải kho lưu trữ bảo mật. Auth token, API key và thông tin đăng nhập phải để trong expo-secure-store hoặc keychain của nền tảng.
Tự viết tay phần persist. Nếu bạn đang đọc AsyncStorage lúc component mount và ghi xuống mỗi khi state thay đổi, thì middleware persist của Zustand đã làm sẵn việc đó, lại lo luôn cả hydration và serialise, mã ít hơn mà lỗi cũng ít hơn.
Tóm lại: dữ liệu từ máy chủ đi qua React Query. Client state nằm trong Zustand. Thứ gì cần giữ lại sau khi khởi động lại thì cho vào AsyncStorage, còn thứ gì nhạy cảm thì cho vào kho lưu trữ bảo mật.
Những kiến trúc tệ nhất tôi từng làm đều có một store khổng lồ mà mọi dữ liệu đều chảy qua. Những kiến trúc tốt nhất thì phân định rõ ràng giữa server state, client state và những gì cần lưu lại lâu dài. Vạch sẵn các ranh giới đó từ sớm thì mọi thứ về sau sẽ dễ thở hơn nhiều.

