Ứ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 trạng thá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. Dữ liệu API lưu đệm, tuỳ chọn của người dùng, trạng thái biểu mẫu, token xác thực, tất cả nhét chung vào một cục Redux. Nhưng đây là những loại trạng thái 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 trạng thái#
Trước khi tranh luận nên dùng thư viện nào, hãy gọi cho đúng tên thứ mình đang quản lý đã.
Trạng thái phía máy khách (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 biểu mẫu, hộp thoại (modal) đang mở hay đóng. Không có ai ở phía máy chủ sở hữu nó, và tiến trình (process) kết thúc thì nó cũng mất theo.
Trạng thái phía máy chủ (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 trên thiết bị. Ví dụ như hồ sơ người dùng, danh sách sản phẩm, thông báo, dữ liệu bảng tin (feed). Bản sao của bạn sẽ dần cũ đi, cần được tải lại, và ngay lúc này rất có thể một máy khách khác đang thấy một phiên bản khác của cùng dữ liệu đó.
Trạng thái lưu bền (persistent state) là những gì phải còn nguyên sau khi khởi động lại ứng dụng: token xác thực, cờ đánh dấu người dùng đã xong phần làm quen ban đầu (onboarding), các tuỳ chọn đã lưu, dữ liệu dùng khi ngoại tuyến (offline). Loại trạng thái 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: trạng thái phía máy khách#
Zustand là một thư viện quản lý trạng thái nhỏ gọn cho React, không có mấy thủ tục rườm rà. Không cần provider, không có mã rập khuôn (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 trạng thái giao diện (thanh bên đang mở hay đóng, bộ lọc đang bật, các mục đang chọn), khi trạng thái ở cấp ứng dụng không đến từ API (chế độ sáng/tối, ngôn ngữ, các cờ tính năng đọc lúc khởi động), hoặc khi có logic phía máy khách thật sự cần quản lý, như tính tiền giỏ hàng hay một trình hướng dẫn nhiều bước (wizard). 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à dữ liệu phản hồi 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ì dùng truy vấn (query). Rốt cuộc bạn phải tự tay viết lại toàn bộ phần vô hiệu hóa bộ nhớ đệm (cache invalidation), trạng thái đang tải, xử lý lỗi, tải lại 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. Trạng thái 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: trạng thái phía máy chủ#
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: tải về (fetch), lưu đệm, đồ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ộ nhớ đệm cần giữ cho mới, chứ không phải trạng thái 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 yêu cầu 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 tải lại ngầm khi người dùng quay lại ứng dụng, và cập nhật lạc quan (optimistic update), tức là giao diện đổi ngay lập tức rồi khôi phục lại 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 người dùng thì chỉ tạo ra đúng một yêu cầu mạng. Dữ liệu tự tải lại khi cửa sổ được chọn lại (focus) hoặc khi có mạng lại. Bộ nhớ đệm cũ tự được dọn. Truy vấn 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 đang tải và lỗi không còn phải làm thủ công từng chỗ. Yêu cầu thất bại được tự động thử lại với thời gian chờ tăng theo cấp số nhân (exponential backoff). Còn cập nhật lạc quan kèm khôi phục khi lỗi là cơ chế 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#
Trạng thái thuần tuý phía máy khách. 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 hộp thoại đâu cần vô hiệu hóa bộ nhớ đệm hay tải lại định kỳ.
React Query cũng không phải tầng lưu trữ lâu dài. Bộ nhớ đệm chỉ nằm trong RAM, nên mỗi lần khởi động lại là trống trơn và phải tải lại một loạt. Bạn có thể lưu bộ nhớ đệm xuống thiết bị (trên React Native thì nên làm, xem phần ngoại tuyến bên dưới), nhưng nguồn dữ liệu gốc (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ữ dạng khóa-giá 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 tiến trình 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 cơ chế lưu trữ 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 cô lập trong môi trường riêng (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 tuần tự hóa (serialise) rồi ghi 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 thư viện bọc (wrapper) như idb-keyval nếu dữ liệu lớn hơn.
| Nền tảng | Cơ chế lưu trữ | 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 đó#
Token xác thực và dữ liệu phiên đăng nhập, các tuỳ chọn cần giữ lại (ngôn ngữ, chế độ sáng/tối, cài đặt thông báo), cờ đã xong phần làm quen ban đầu, dữ liệu lưu đệm để dùng khi ngoại tuyến. Tóm lại là những mẩu dữ liệu khóa-giá trị 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 khóa-giá trị, 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 chỉ mục hay truy vấn 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 mẫu (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 tải 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.
Chế độ sáng/tối 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ý trạng thái trong lúc ứng dụng chạy, còn middleware persist âm thầm đồng bộ trạng thái đó 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 hướng ưu tiên ngoại tuyến#
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 tải và lưu đệm trong bộ nhớ, còn AsyncStorage là phương án dự phòng khi mất mạng.
Làm chế độ ngoại tuyến, 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 biểu tượng đang tải cứ 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 thư viện này, bạn đã dựng được một hệ thống chạy ngoại tuyến 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 mất mạng#
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 sự kiện, 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 dải thông báo mất mạng, 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ế độ ngoại tuyến#
React Query có sẵn cơ chế xử lý ngoại tuyến thông qua networkMode, quyết định truy vấn và thao tác ghi (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) | Truy vấn chỉ chạy khi có mạng. Mất mạng thì tạm dừng. |
always | Truy vấn chạy bất kể có mạng hay không. queryFn của bạn phải tự xử lý lỗi. |
offlineFirst | Truy vấn chạy một lần (để lấy dữ liệu lưu đệm), rồi tạm dừng, đợi có mạng mới tải 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 lưu đệm hiện ra ngay, còn dữ liệu mới sẽ về khi có mạng.
Bước 3: lưu bộ nhớ đệm truy vấn xuống thiết bị#
Mặc định, bộ nhớ đệm chỉ nằm trong RAM, nên mỗi lần khởi động lại là biểu tượng đang tải quay khắp màn hình. Muốn dùng được khi ngoại tuyến thì bạn lưu 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 tải về 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ẽ tải dữ liệu mới.
Bước 4: xếp hàng các thao tác ghi khi mất mạng#
Đọc là phần dễ. Ghi mới là chỗ chế độ ngoại tuyến 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 cập nhật lạc quan 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 thao tác ghi, mutationFn sẽ tạm dừng cho tới khi có mạng trở lại. Cập nhật lạc quan đư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ể lưu luôn cả hàng đợi thao tác ghi xuống thiết bị:
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: thao tác ghi đang tạm dừng sẽ chạy tiếp, truy vấn đã cũ sẽ được tải lại. Bạn chỉ cần nối các trình quản lý (manager) của nó với sự kiện 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 việc tải lại để 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 ngoại tuyến 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 trạng thái có mạng/mất mạng, điều khiển dải thông báo trên giao diện |
| Tải dữ liệu | React Query | Tải khi có mạng, trả dữ liệu lưu đệm khi mất mạng |
| Lưu bộ nhớ đệm | React Query + AsyncStorage | Khôi phục bộ nhớ đệm khi khởi động lại ứng dụng |
| Ghi khi mất mạng | Thao tác ghi của React Query | Xếp hàng thao tác ghi, gửi lại khi có mạng |
| Giao diện cập nhật lạc quan | onMutate của React Query | Hiện thay đổi ngay, khôi phục lại nếu thất bại |
| Đồng bộ khi ứng dụng được mở lại | focusManager của React Query | Tải lại dữ liệu cũ khi ứng dụng quay lại tiền cảnh (foreground) |
Khi nào cần một công cụ đồng bộ thực thụ#
Tất cả những gì ở trên đều giả định máy chủ là nguồn dữ liệu gốc và việc mất mạng chỉ là tạm thời. Nếu bạn cần ưu tiên ngoại tuyế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 ngoại tuyến), thì bộ công nghệ này không kham nổi, và bạn cần một công cụ đồng bộ (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 đồng bộ để giải quyết xung đột. Realm kết hợp Atlas Device Sync là một cơ sở dữ liệu ưu tiên ngoại tuyến đầy đủ, tự động giải quyết xung đột qua MongoDB Atlas. PowerSync là một tầng đồng bộ dựa trên SQLite, chạy được với cơ sở dữ liệu Postgres sẵn có ở phía máy chủ 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 đồng bộ.
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 phụ thuộc nhiều vào chế độ ngoại tuyến trên nhiều thiết bị, thì cần một cơ sở dữ liệu có đồng bộ chuyên dụng.
Một mẩu trạng thái 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 trạng thái giao diện chỉ ở phía máy khách 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 truy vấn không? | SQLite / WatermelonDB / Realm |
| Có phải một ô nhập liệu đơn giản, chỉ một component dùng không? | useState |
Các trường hợp thường gặp#
| Trạng thái | Công cụ | Lý do |
|---|---|---|
| Dữ liệu phản hồi từ API | React Query | Lưu đệm, gộp yêu cầu trùng, tải lại, trạng thái đang tải |
| Tab đang chọn / bộ lọc đang bật | Zustand | Chỉ ở máy khách, nhiều component cần dùng |
| Token xác thực | AsyncStorage + Zustand | Giữ qua các lần khởi động lại, đọc nhanh từ bộ nhớ |
| Tuỳ chọn chế độ sáng/tối | Zustand với middleware persist | Trạng thái phía máy khách cần giữ lại sau khi khởi động lại |
| Giỏ hàng | Zustand với middleware persist | Logic phía máy khách phức tạp, cần giữ lại sau khi khởi động lại |
| Ô nhập liệu trong biểu mẫu | useState | Chỉ một component, không cần chia sẻ |
| Hồ sơ người dùng từ API | React Query | Trạng thái phía máy chủ, có thể đã cũ |
| Cờ đã xong phần làm quen ban đầu | AsyncStorage | Chỉ là một giá trị boolean cần lưu lại |
| Bảng tin lưu đệm để xem khi mất mạng | React Query + AsyncStorage | Tải từ máy chủ, lỗi thì quay về dùng bản lưu đệm |
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 bước liên kết mã native (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 cơ chế vô hiệu hóa bộ nhớ đệm, mà còn chế dở.
Dùng React Query cho trạng thái phía máy khách. Nếu queryFn không hề gửi yêu cầu mạng nào thì bạn đang dùng sai công cụ.
Dùng AsyncStorage như cơ sở dữ liệu. Tuần tự hóa một mảng 10,000 phần tử rồi nhét vào kho khóa-giá trị 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. Token xác thực, 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 lưu trạng thái xuống thiết bị. Nếu bạn đang đọc AsyncStorage lúc component được gắn vào (mount) và ghi xuống mỗi khi trạng thái thay đổi, thì middleware persist của Zustand đã làm sẵn việc đó, lại lo luôn cả khâu nạp lại trạng thái (hydration) và tuần tự hóa, 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. Trạng thái phía máy khách 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 trạng thái phía máy chủ, trạng thái phía máy khách 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.
