Luyện Phỏng Vấn IT — 2000+ Câu Hỏi Phỏng Vấn IT Có Đáp Án 2026
State Management
Redux phù hợp cho complex global state cần time-travel debug; RTK là cách dùng Redux 2025.
- Redux là thư viện quản lý state tập trung cho JavaScript, thường dùng với React.
- Nó lưu toàn bộ state ứng dụng trong một store duy nhất, giúp dễ dàng theo dõi, debug và chia sẻ state giữa các component không liên kết trực tiếp.
Ví dụ thực tế: trong app có UserProfile ở header và ShoppingCart ở sidebar cùng cần thông tin user đang đăng nhập — không cần prop drilling qua nhiều cấp, cả hai đọc thẳng từ Redux store.
- Redux đặc biệt có giá trị khi dùng Redux DevTools để time-travel debug, replay lại từng action để tái hiện bug.
Pitfall: nhiều team dùng Redux cho mọi thứ kể cả state local của form — điều đó làm code phức tạp không cần thiết, chỉ nên dùng Redux cho state thật sự cần chia sẻ toàn app.
RTK eliminates Redux boilerplate via createSlice, createAsyncThunk, và RTK Query.
Ba nguyên tắc cốt lõi của Redux:
- Single source of truth — toàn bộ state nằm trong một store duy nhất, giúp đồng bộ data giữa các component và dễ serialize/restore state (ví dụ: lưu state vào localStorage rồi hydrate lại khi reload).
- State is read-only — không được mutate state trực tiếp, chỉ thay đổi qua dispatch action, điều này tạo ra audit trail rõ ràng cho mọi thay đổi, là nền tảng để Redux DevTools time-travel hoạt động.
- Changes are made with pure functions — reducer phải là pure function, cùng input luôn cho cùng output, không có side effects. Pitfall phổ biến: vi phạm nguyên tắc
- bằng cách gọi API hoặc
Date.now()trong reducer — side effects phải nằm trong middleware (thunk/saga). Nhiều developer mới cũng vi phạm nguyên tắc - bằng cách
state.items.push(item)trong Redux thuần — chỉ Redux Toolkit với Immer mới cho phép cú pháp này
Action là plain JavaScript object mô tả sự kiện xảy ra trong ứng dụng, bắt buộc có trường type (string định danh) và thường có payload chứa dữ liệu.
Ví dụ: { type: 'cart/addItem', payload: { id: 1, name: 'iPhone', qty: 1 } }.
- Quy ước đặt tên
typetheo formatdomain/eventNamegiúp dễ đọc trong DevTools. - Action creator là function trả về action object:
const addItem = (item) => ({ type: 'cart/addItem', payload: item })— giúp tránh typo và tái sử dụng. - Với Redux Toolkit,
createSlicetự động tạo action creators và action types, không cần viết tay.
Pitfall: đừng đặt non-serializable values vào action (Promise, class instance, function) vì sẽ phá vỡ DevTools time-travel và middleware như redux-persist.
Reducer là pure function nhận (state, action) và trả về state mới — không được mutate state trực tiếp mà phải tạo bản sao mới.
Ví dụ đúng: case 'ADD_TODO': return [...state, action.payload] thay vì state.push(action.payload). Pure function nghĩa là không có side effects, không gọi API, không random — cùng input luôn cho cùng output, điều này giúp Redux DevTools time-travel hoạt động được. Luôn có default: return state để tránh trả về undefined khi action không khớp. Với Redux Toolkit, dùng Immer bên trong nên được phép viết state.todos.push(item) mà vẫn immutable thật sự.
Store lưu toàn bộ state tree trong một object duy nhất — dùng configureStore() (RTK) thay vì createStore() (legacy); useSelector/useDispatch là interface chính cho React components.
- Store là object trung tâm lưu trữ toàn bộ state tree của ứng dụng, được tạo bằng
configureStore()(RTK) hoặccreateStore()(legacy). - Ba phương thức chính:
getState()trả về state hiện tại,dispatch(action)gửi action qua middleware rồi đến reducer để cập nhật state,subscribe(listener)đăng ký callback được gọi sau mỗi dispatch. - Trong thực tế với React, bạn hiếm khi gọi trực tiếp các phương thức này vì
react-reduxđã wrap chúng quauseSelector(thay getState + subscribe) vàuseDispatch(thay dispatch).
Pitfall: chỉ nên có MỘT store duy nhất trong app — nhiều store phá vỡ nguyên tắc single source of truth và làm DevTools không hoạt động đúng.
- Với RTK,
configureStoretự động setup Redux DevTools, thunk middleware, và development checks (phát hiện accidental mutation).
Zustand là thư viện quản lý state nhỏ gọn cho React, không yêu cầu boilerplate như Redux — không cần actions, reducers, dispatch, hay Provider wrapper.
- API chỉ gồm hàm
createtrả về một hook, state và actions định nghĩa cùng chỗ. - Bundle size ~1KB so với Redux Toolkit ~40KB khi đầy đủ.
Ví dụ tạo store đơn giản: const useStore = create(set => ({ count: 0, inc: () => set(s => ({ count: s.count + 1 })) })), dùng trong component: const count = useStore(s => s.count).
Pitfall quan trọng: luôn dùng selector khi lấy state (useStore(s => s.count) thay vì useStore()) để tránh re-render không cần thiết khi phần state khác thay đổi.
Dùng create từ 'zustand', truyền vào initializer function nhận (set, get) và trả về object chứa cả state lẫn actions trong cùng một chỗ. set() merge partial state (không cần spread toàn bộ như Redux). get() đọc state hiện tại từ bên trong action.
Ví dụ đầy đủ: const useCartStore = create((set, get) => ({ items: [], total: 0, addItem: (item) => set((s) => ({ items: [...s.items, item], total: s.total + item.price })), clearCart: () => set({ items: [], total: 0 }), getItemCount: () => get().items.length })).
- Store trả về là một React hook, dùng trực tiếp:
const items = useCartStore(s => s.items)hoặcconst addItem = useCartStore(s => s.addItem). - Không cần Provider, không cần dispatch — gọi action như gọi function bình thường:
addItem(product). - So sánh với Redux: 1 file slice Zustand thay thế được actions + actionTypes + reducer + selectors + thunk.
React Query (nay là TanStack Query v5) là thư viện quản lý server state cho React, giải quyết bài toán mà useState + useEffect không làm tốt: fetching, caching, synchronizing và updating data từ API.
Ví dụ thực tế: không cần React Query, bạn phải tự viết loading state, error handling, cache, deduplication, refetch khi focus window, retry khi fail — React Query làm tất cả tự động.
- Khác biệt cốt lõi với Redux: Redux quản lý client state (UI state, form state), React Query quản lý server state (data từ API có thể thay đổi bởi người khác bất kỳ lúc nào).
- Trong thực tế, 80% Redux code trong các dự án là để fetch và cache API data — React Query thay thế hoàn toàn phần đó.
useQuery là hook chính để đọc data, nhận 2 tham số bắt buộc: queryKey (mảng định danh duy nhất, ví dụ ['todos', { status: 'active' }]) và queryFn (async function trả về data, ví dụ () => fetch('/api/todos').then(r => r.json())).
- Trả về object gồm:
data(kết quả),isLoading(true lần fetch đầu),isFetching(true khi đang fetch kể cả background),isError/error(lỗi),isSuccess,status. - Cơ chế: component mount → check cache theo queryKey → nếu có data fresh thì dùng luôn, nếu stale thì vừa show cached data vừa refetch background.
- Đây là pattern stale-while-revalidate.
QueryClientProvider là Context Provider bắt buộc — tạo QueryClient BÊN NGOÀI component, wrap app để mọi useQuery/useMutation đều access được cache.
- QueryClientProvider là React Context Provider cung cấp QueryClient instance cho toàn bộ component tree.
- QueryClient là 'bộ não' quản lý cache, chứa tất cả cached data, query states, và config.
- Mọi
useQuery/useMutationbên trong đều cần access QueryClient. - Setup: tạo
const queryClient = new QueryClient()BÊN NGOÀI component (tránh re-create mỗi render), wrap app bằng<QueryClientProvider client={queryClient}>. - Có thể cấu hình defaultOptions cho tất cả queries:
new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, retry: 2 } } }). - Trong Next.js App Router, đặt ở root layout nhưng cần 'use client' wrapper.
isLoading = true chỉ lần fetch đầu chưa có cache (show skeleton toàn trang); isFetching = true MỌI KHI đang fetch kể cả background refetch (show subtle indicator).
- Đây là câu phỏng vấn rất phổ biến. isLoading (hay
status === 'pending'): true CHỈ khi fetch lần đầu VÀ chưa có cached data — dùng để show loading skeleton/spinner toàn trang. isFetching: true MỌI KHI đang fetch, kể cả background refetch khi đã có cached data — dùng để show subtle indicator (spinning icon nhỏ, progress bar mờ).
Ví dụ thực tế: user vào trang Products lần đầu → isLoading=true, show skeleton.
- User navigate đi rồi quay lại → có cached data nên isLoading=false (show data cũ ngay), nhưng isFetching=true (đang refetch background).
- Thêm
isRefetching= isFetching && !isLoading (đang refetch có data cũ).
RTK là cách duy nhất được khuyến nghị để viết Redux hiện đại — createSlice gộp actions+reducer, Immer cho phép write mutable syntax, RTK Query thay thế thunk cho data fetching.
Redux Toolkit (RTK) là bộ công cụ chính thức và là cách duy nhất được khuyến nghị để viết Redux hiện đại — Redux thuần giờ được coi là legacy.
RTK giải quyết 3 vấn đề lớn của Redux thuần:
- quá nhiều boilerplate (actions, action types, reducers, combineReducers riêng biệt) →
createSlicegộp tất cả vào một chỗ. - Immutable update phức tạp (
{...state, nested: {...state.nested, value: newVal}}) → Immer bên trong cho phép viếtstate.nested.value = newVal. - Async logic verbose →
createAsyncThunkxử lý pending/fulfilled/rejected tự động
Thêm RTK Query cho data fetching và caching, cạnh tranh trực tiếp với React Query.
Ví dụ thực tế: 1 feature Redux thuần cần 4-5 files (types, actions, reducer, selectors, thunks), RTK chỉ cần 1 file slice.
Pitfall: khi migrate từ Redux thuần sang RTK, đừng rewrite toàn bộ — RTK tương thích ngược, migrate từng slice một.
createSlice gộp actions, action types, và reducer vào một chỗ — Immer bên trong cho phép viết mutable syntax mà vẫn immutable thật sự. createSlice là API quan trọng nhất của RTK, nhận object gồm name (prefix cho action types), initialState, và reducers (object chứa các reducer functions).
- Nó tự động tạo action creators và action types — ví dụ: reducer
incrementtrong slice tên 'counter' tạo ra action typecounter/incrementvà action creatorcounterSlice.actions.increment(). - Bên trong dùng Immer nên viết
state.value += 1thay vìreturn { ...state, value: state.value + 1 }— code ngắn hơn 3-4 lần với nested state. - Export 2 thứ:
counterSlice.actions(dùng trong component dispatch) vàcounterSlice.reducer(dùng trong configureStore).
Pitfall thường gặp: trong reducers của createSlice, hoặc mutate state HOẶC return state mới, KHÔNG làm cả hai — Immer sẽ throw error.
Ví dụ sai: state.items.push(item); return state;.
createAsyncThunk tự động dispatch pending/fulfilled/rejected — luôn dùng rejectWithValue thay vì throw để error serializable. createAsyncThunk tạo thunk action creator xử lý async logic (API calls), tự động dispatch 3 lifecycle actions: pending (request bắt đầu → show loading), fulfilled (thành công → update data), rejected (thất bại → show error).
Ví dụ: createAsyncThunk('users/fetch', async (userId, { rejectWithValue }) => { try { return await api.getUser(userId) } catch (err) { return rejectWithValue(err.response.data) } }).
- Xử lý trong slice dùng
extraReducersvới builder pattern:builder.addCase(fetchUser.pending, (state) => { state.loading = true }).
Pitfall: luôn dùng rejectWithValue thay vì throw error trực tiếp — nếu không, error object không serializable và DevTools hiển thị sai.
- Cũng nên dùng
conditionoption để cancel thunk nếu data đã có trong cache, tránh duplicate API calls. - Với RTK Query, nhiều use case của createAsyncThunk có thể thay thế hoàn toàn mà không cần viết reducer.
createSelector memoize kết quả selector — chỉ recompute khi input thay đổi, tránh re-render không cần thiết khi filter/map trả về reference mới.
- Selector là function nhận toàn bộ Redux state và trích xuất/tính toán phần data cần thiết, ví dụ:
const selectActiveTodos = state => state.todos.filter(t => !t.completed). - Vấn đề:
filter()tạo array mới mỗi lần gọi →useSelectorthấy reference khác → component re-render dù data không đổi.createSelectortừ Reselect giải quyết bằng memoization: chỉ tính toán lại khi input selectors trả về giá trị khác.
Ví dụ: createSelector([selectTodos, selectFilter], (todos, filter) => todos.filter(t => t.status === filter)) — nếu todos và filter không đổi, trả về kết quả cached.
Pitfall phổ biến: tạo selector bên trong component khiến memoization không hoạt động vì selector mới được tạo mỗi render — luôn định nghĩa selector bên ngoài component hoặc dùng useMemo.
- Trong app lớn, selector composition (selector dùng selector khác) giúp tái sử dụng logic tính toán.
Middleware là function nằm giữa dispatch(action) và reducer, có thể inspect, modify, delay, hoặc cancel action trước khi đến reducer.
- Flow:
dispatch → middleware1 → middleware2 → reducer. - Middleware phổ biến:
redux-thunk(cho phép dispatch function thay vì object, xử lý async — RTK include mặc định),redux-saga(dùng generator functions cho complex async flows như race conditions, cancellation, debounce),redux-logger(log action type, prev state, next state — hữu ích cho debug).
Ví dụ custom middleware: const logger = store => next => action => { console.log(action.type); return next(action) }.
Pitfall: thứ tự middleware quan trọng — thunk phải ở đầu để dispatch(function) được xử lý trước khi đến middleware khác.
- Với RTK,
configureStoretự setup thunk + serialization check + immutability check, chỉ cần thêm custom middleware quamiddlewareoption nếu cần.
useSelector nhận selector function, subscribe vào Redux store, và dùng strict === comparison (reference equality) để quyết định có re-render không.
Pitfall quan trọng: nếu selector trả về object/array mới mỗi lần gọi — ví dụ useSelector(s => s.items.filter(...)) — reference luôn khác nên component re-render liên tục dù data không đổi.
- Fix: dùng
shallowEqualtừ react-redux làm argument thứ hai (useSelector(selector, shallowEqual)) để so sánh từng property, hoặc dùngcreateSelector(Reselect) để memoize. useDispatch trả về stable dispatch reference — không bao giờ thay đổi giữa các render, an toàn khi đặt vào dependency array. - So sánh với connect() HOC cũ: connect() dùng shallowEqual mặc định nên ít gặp re-render issue hơn, nhưng code verbose (mapStateToProps, mapDispatchToProps) và wrap component trong HOC gây khó debug. useSelector/useDispatch code gọn hơn 50% nhưng developer phải tự xử lý equality.
Ví dụ pitfall thực tế: useSelector(s => ({ a: s.a, b: s.b })) tạo object mới mỗi render → infinite re-render loop khi có dispatch trong useEffect — fix bằng 2 useSelector riêng biệt hoặc shallowEqual.
Actions trong Zustand là plain functions sống cùng state trong store object — không có action types, không dispatch, không reducers.
Có 3 pattern chính:
- Partial update với set:
increment: () => set(s => ({ count: s.count + 1 })). - Computed values dùng get() để đọc state hiện tại bên trong action:
addItem: (item) => { const existing = get().items.find(i => i.id === item.id); set(s => ({ items: existing ? s.items.map(i => i.id === item.id ? {...i, qty: i.qty+1} : i) : [...s.items, item] })) }. - Async actions — không cần thunk middleware, viết async/await trực tiếp:
fetchUser: async (id) => { set({ loading: true }); const user = await api.getUser(id); set({ user, loading: false }) }
Multiple state updates trong một set call tự động batched.
Pitfall: set merge shallow — nested object phải tự spread: set(s => ({ config: { ...s.config, theme: 'dark' } })).
Kiến trúc khác nhau căn bản: Redux theo Flux pattern — action object → dispatch → middleware → reducer → new state; Zustand không có khái niệm action types hay reducers — chỉ là functions gọi set().
- Redux bắt buộc: Provider wrapper, combineReducers, action creators, selectors riêng biệt, middleware để async.
- Zustand không cần gì trong số đó.
- Redux enforces một flow dữ liệu nghiêm ngặt (tốt cho team lớn, audit), Zustand flexible hơn (tốt cho DX và speed).
- Về middleware: Redux có ecosystem middleware phong phú (thunk, saga, logger, sentry), Zustand có middleware nhẹ hơn (devtools, persist, immer) nhưng đủ dùng cho hầu hết cases.
- Về DevTools: cả hai đều kết nối được Redux DevTools Extension.
- Khi Zustand tỏa sáng: prototype nhanh, app vừa, team nhỏ.
- Khi Redux tỏa sáng: large team cần consistency, cần RTK Query, cần full audit trail, cần time-travel debugging end-to-end.
Luôn dùng selector cụ thể trong Zustand — gọi useStore() không có selector subscribe toàn bộ store và re-render khi bất kỳ state nào thay đổi.
Zustand mặc định dùng strict === equality để so sánh — nếu selector trả về primitive (number, string, boolean) thì không vấn đề gì.
Vấn đề xảy ra khi selector trả về object/array mới mỗi lần: useStore(s => ({ a: s.a, b: s.b })) tạo object mới mỗi render → component luôn re-render.
Giải pháp:
- Tách thành 2 selectors riêng:
const a = useStore(s => s.a); const b = useStore(s => s.b). - Dùng
useShallowhook từ 'zustand/react/shallow' để shallow compare object/array:const { a, b } = useStore(useShallow(s => ({ a: s.a, b: s.b }))). - Dùng
createSelectorspattern để auto-generate per-property selectors
Auto-selector pattern: const createSelectors = (store) => { const s = store; s.use = {}; Object.keys(s.getState()).forEach(k => { s.use[k] = () => s(x => x[k]) }); return s } → dùng useStore.use.count().
Pitfall khác: gọi useStore() không truyền selector → subscribe toàn bộ store, re-render khi bất kỳ state nào thay đổi.
Zustand middleware wrap store creator với higher-order function — devtools, immer, persist là ba middleware phổ biến nhất; compose bằng cách nest lồng nhau, devtools ở ngoài cùng.
Zustand middleware dùng pattern higher-order function wrap store creator.
Import từ 'zustand/middleware'.
Compose nhiều middleware bằng cú pháp lồng nhau: create(devtools(immer(persist(storeCreator, persistOptions)), devtoolsOptions)) — thứ tự quan trọng: devtools nên ở ngoài cùng để capture đúng actions.
Middleware phổ biến:
devtools— kết nối Redux DevTools Extension, đặt tên action cho mỗi set call:set(updater, false, 'addItem')để log đẹp trong DevTools;immer— cho phép mutate state trực tiếp trong set:set(s => { s.items.push(item) })thay vì phải spread;persist— lưu/khôi phục state vào storage;subscribeWithSelector— cho phép subscribe vào một phần state với selector và equality function, hữu ích cho side effects
Custom middleware: function nhận (config) => (set, get, api) => config(set, get, api) với logic thêm vào trước/sau set.
persist middleware serialize state ra storage và rehydrate khi app khởi động — dùng partialize để chỉ lưu phần cần thiết và version+migrate để handle schema changes.
- Middleware
persisttừ 'zustand/middleware' serialize state thành JSON rồi lưu vào storage, rehydrate khi app khởi động. - Config quan trọng:
name(storage key, bắt buộc),partializeđể chọn phần state cần persist — tránh lưu thừa:partialize: (s) => ({ user: s.user, theme: s.theme })(không lưu loading/error states),storageđể override engine:createJSONStorage(() => sessionStorage)hoặc custom AsyncStorage cho React Native.onRehydrateStoragelà lifecycle hook chạy khi rehydrate:onRehydrateStorage: () => (state, error) => { if (error) console.error('hydration failed', error) }.version+migrateđể handle schema changes giữa các app versions:version: 2, migrate: (persistedState, version) => { if (version === 1) return { ...persistedState, newField: 'default' } }.
Pitfall: persist không merge deep — nếu cấu trúc state thay đổi giữa releases mà không có migrate, rehydrate sẽ cho state không hợp lệ.
useMutation xử lý write operations (POST/PUT/DELETE) — khác useQuery chỉ đọc.
Cung cấp mutate() (fire-and-forget) hoặc mutateAsync() (trả về Promise).
States: isPending, isError, isSuccess, data, error.
Optimistic update flow:
onMutate: cancel ongoing queries, snapshot data cũ, set cache mới ngay → UI cập nhật tức thì.onError: nhận snapshot từ onMutate context, rollback cache.onSettled: invalidate queries để sync với server
Ví dụ thực tế: user like bài viết → heart đỏ ngay lập tức (optimistic), nếu API fail → rollback heart về trắng.
Cache invalidation là cơ chế đánh dấu data đã cũ (stale) để trigger refetch.
- Cách dùng:
queryClient.invalidateQueries({ queryKey: ['todos'] })— tất cả queries có key bắt đầu bằng 'todos' bị đánh dấu stale. - Nếu component đang mount thì refetch ngay, nếu không thì refetch khi component mount lại.
- Thường gọi sau mutation thành công: thêm todo mới → invalidate todo list → list tự động refetch.
- Ngoài ra có
queryClient.setQueryData()để update cache trực tiếp mà không cần refetch — hữu ích khi mutation trả về data mới.
staleTime kiểm soát khi nào refetch; gcTime kiểm soát khi nào xóa cache — staleTime nên ≤ gcTime.
- Hai config quan trọng nhất quyết định caching behavior. staleTime (mặc định 0): thời gian data được coi là 'fresh' — trong khoảng này component mount mới sẽ dùng cached data mà KHÔNG refetch.
Ví dụ: staleTime: 5 60 1000 (5 phút) → trong 5 phút, navigate qua lại giữa các page sẽ show cached data ngay, không loading. gcTime (mặc định 5 phút): thời gian data ở lại trong cache SAU KHI không còn component nào subscribe.
- Khi hết gcTime, data bị xóa khỏi memory.
- Quan hệ: staleTime quyết định 'khi nào refetch', gcTime quyết định 'khi nào xóa cache'. staleTime nên ≤ gcTime.
Server state thuộc server (có thể thay đổi bởi người khác bất kỳ lúc nào) — React Query quản lý caching/sync, không phải "global state" như Redux.
- Có 2 loại state khác biệt bản chất: Client state (theme, sidebar open, form input) — thuộc sở hữu hoàn toàn của client, đồng bộ ngay lập tức, không bao giờ 'stale'. Server state (user profile, todo list, product catalog) — thuộc sở hữu của server, có thể bị thay đổi bởi người khác, cần đồng bộ liên tục, luôn có khả năng 'stale'.
- Redux trộn lẫn 2 loại này vào 1 store, dẫn đến phức tạp không cần thiết (loading states, error states, normalization).
- React Query tách biệt: nó CHỈ quản lý server state với caching, background refetch, retry, deduplication.
- Client state dùng useState/useReducer/Zustand.
- Kết quả: giảm 50-80% Redux code trong hầu hết projects.
Tạo QueryClient BÊN NGOÀI component; dùng cache() factory trong Next.js App Router để mỗi request server có instance riêng — tránh data leak giữa users.
- QueryClient là singleton quản lý toàn bộ cache và config.
- Tạo BÊN NGOÀI component để tránh re-create mỗi render. defaultOptions áp dụng cho tất cả queries/mutations trừ khi override per-query:
new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, gcTime: 5 60_000, retry: 2, retryDelay: (attempt) => Math.min(1000 2 ** attempt, 30_000), refetchOnWindowFocus: false } } }). - Retry strategy: mặc định retry 3 lần với exponential backoff, có thể set
retry: (count, error) => error.status !== 404 && count < 3để không retry 404. - Error handling defaults: từ v5 không còn
onErrorglobal — dùngQueryCachelistener:new QueryClient({ queryCache: new QueryCache({ onError: (error) => toast.error(error.message) }) }). staleTime strategy theo loại data: static data (currencies, countries) →Infinity, user data → 5 phút, real-time data → 0. - Trong Next.js App Router, tạo QueryClient trong server component và dùng
makeQueryClient()factory pattern để mỗi request có instance riêng tránh data leaking giữa users.
refetchOnWindowFocus (mặc định bật) refetch tất cả stale queries khi user quay lại tab — tắt khi data ít thay đổi hoặc refetch gây UX kém. refetchOnWindowFocus là behavior mặc định: khi user chuyển tab rồi quay lại, React Query tự động refetch tất cả stale queries.
Mục đích: đảm bảo data luôn fresh — ví dụ user đang xem dashboard, chuyển qua Slack 5 phút, quay lại thì data cập nhật mới nhất.
Nên tắt (refetchOnWindowFocus: false) khi:
- data ít thay đổi (config, static content),
- refetch gây UX tệ (reset scroll, flash loading),
- API có rate limit
Có thể set global qua defaultOptions hoặc per-query.
Tương tự có refetchOnReconnect (khi mạng khôi phục) và refetchInterval (polling định kỳ).
React Query v5 thay đổi error handling so với v4: onError callback bị deprecated, thay bằng throwOnError hoặc meta.
- Retry strategies: mặc định retry 3 lần, exponential backoff — override:
retry: (failureCount, error) => error.status !== 401 && failureCount < 3để không retry auth errors. - Global error handling với QueryCache observer:
new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { if (query.meta?.showErrorToast) toast.error(error.message) } }) })— per-query opt-in:useQuery({ ..., meta: { showErrorToast: true } }). - Error Boundary integration:
throwOnError: truekhiến query throw error lên Error Boundary thay vì returnisError. - Hoặc dùng
useSuspenseQuery— tự động throw lên Error Boundary.error.messagevserror.response.data: với fetch API, HTTP errors không tự throw — phải checkif (!res.ok) throw new Error(res.statusText)trong queryFn.
Pitfall v5: onError ở useMutation vẫn hoạt động nhưng global onError trong defaultOptions đã bị xóa.
QueryKey design là nền tảng của cache architecture.
Rules:
- Include ALL dependencies — bất cứ biến nào dùng trong queryFn phải có trong key:
['todos', userId, { status, page, sort }]. - Dùng array, không dùng string thuần — array cho phép fuzzy matching khi invalidate.
- Hierarchy từ general đến specific:
['todos']→['todos', todoId]→['todos', todoId, 'comments']
Factory pattern (best practice): const todoKeys = { all: ['todos'] as const, lists: () => [...todoKeys.all, 'list'] as const, list: (filters) => [...todoKeys.lists(), filters] as const, detail: (id) => [...todoKeys.all, 'detail', id] as const } — dùng: useQuery({ queryKey: todoKeys.detail(id) }), invalidate: queryClient.invalidateQueries({ queryKey: todoKeys.lists() }).
Fuzzy matching rules: invalidateQueries({ queryKey: ['todos'] }) invalidates tất cả keys bắt đầu bằng 'todos' — cả ['todos', 1] lẫn ['todos', { status: 'active' }].
Serialization: React Query serialize key thành stable string cho cache lookup — objects được serialized theo key order.
Jotai là thư viện atomic state management tương tự Recoil nhưng nhỏ gọn hơn (~3KB vs ~21KB) và API đơn giản hơn đáng kể.
- Điểm khác biệt chính: Jotai không yêu cầu
keystring duy nhất cho mỗi atom (Recoil bắt buộc, dễ gây conflict trong large codebase), không cầnRecoilRootwrapper (Jotai dùng WeakMap nên hoạt động không cần Provider). - Cú pháp gần với useState hơn:
const countAtom = atom(0), trong component:const [count, setCount] = useAtom(countAtom). - Jotai cũng hỗ trợ derived atoms, async atoms với Suspense, và có thể dùng
atomWithStorageđể persist. - Tính đến 2024-2026, Jotai đã thay thế Recoil thực tế: Meta archive repo Recoil đầu 2024 (không còn maintain), Jotai trở thành lựa chọn mặc định cho atomic state.
- Primitive atom:
const countAtom = atom(0)— trong component dùnguseAtom(countAtom)trả về[value, setValue]. - Derived read-only:
const doubleAtom = atom(get => get(countAtom) * 2)— dùnguseAtomValue. - Derived read-write:
atom(get => get(baseAtom), (get, set, newVal) => set(baseAtom, newVal * 2)).atomWithStorage('key', defaultVal)từjotai/utilstự đồng bộ localStorage, handle SSR hydration.atomWithDefault(get => get(otherAtom))tạo atom có thể override nhưng mặc định derive. - Architecture Provider-less: Jotai dùng WeakMap lưu state theo atom object reference thay vì string key như Recoil — atoms tự nhiên unique, hỗ trợ code-split, không lo duplicate key.
useAtomValue(chỉ đọc) vàuseSetAtom(chỉ ghi, không subscribe, không re-render) là hooks tối ưu performance.
Provider-less (mặc định): Jotai dùng global WeakMap store — mọi component trong app share cùng atom values mà không cần wrap Provider.
Khi nào cần Provider:
- Testing —
<Provider store={createStore()}>tạo isolated store mỗi test, tránh state leak giữa tests; - Micro-frontend — mỗi app widget có store riêng;
- Reset toàn bộ atoms khi component unmount (ví dụ modal dialog)
Store API: const myStore = createStore(); myStore.get(atom); myStore.set(atom, value); myStore.sub(atom, callback) — dùng để interact với atoms ngoài React (WebSocket handlers, analytics).
Tradeoff: Provider-less tiện nhưng dễ gây test pollution nếu không reset; with-Provider an toàn hơn cho testing nhưng cần boilerplate. <Provider store={store}> cũng là cách implement DevTools hoặc persist toàn bộ store state.
Context API: state đơn giản, ít thay đổi (theme, auth, locale) — không cần cài thêm gì. Zustand: nhẹ (~1KB), ít boilerplate, API đơn giản, phù hợp đa số projects. Redux Toolkit: enterprise, complex state cần middleware (async thunks, saga), DevTools mạnh, team lớn cần convention rõ ràng. Jotai: atomic state (mỗi state là 1 atom độc lập), tối ưu re-render vì chỉ component dùng atom đó mới update.
Lưu ý: Recoil (Meta) đã ngừng phát triển — nên chuyển sang Jotai. Thực tế hiện tại: Zustand phổ biến nhất cho project mới vì đơn giản và đủ mạnh.
createSlice kết hợp action creators + reducer trong cùng một file, dùng Immer nên có thể mutate state trực tiếp.
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser(state, action) { state.name = action.payload; }
}
});
export const { setUser } = userSlice.actions;
export default userSlice.reducer;initialData được treat như real cached data và ảnh hưởng staleTime; placeholderData là temporary fill UI (isPlaceholderData=true), không persist vào cache — dùng placeholderData: prev => prev để giữ data trang trước khi paginate.
- Hai option này phục vụ mục đích khác nhau.
placeholderData: data tạm thời để fill UI trong khi fetch, KHÔNG được lưu vào cache, không có timestamp,isPlaceholderData: trueđể component biết đang hiển thị placeholder. - Dùng khi: muốn show skeleton data có structure (ví dụ array 5 empty items), hoặc
placeholderData: (prev) => prevđể giữ data page trước khi chuyển page (keepPreviousData pattern).initialData: được treat như real cached data — códataUpdatedAt, ảnh hưởng staleTime (nếu initialData đủ fresh thì không refetch ngay). - Dùng khi: data đã có từ nguồn khác (ví dụ đã fetch list, giờ dùng item từ list làm initialData cho detail query:
initialData: () => queryClient.getQueryData(['todos'])?.find(t => t.id === id),initialDataUpdatedAt: () => queryClient.getQueryDataState(['todos'])?.dataUpdatedAt).
Pitfall: dùng sai initialData với hardcoded value sẽ ngăn query refetch vì React Query nghĩ data còn fresh — luôn kết hợp với initialDataUpdatedAt: 0 nếu muốn force refetch.
select transform data sau fetch trước khi component nhận — cache vẫn lưu raw data; component chỉ re-render khi select result thay đổi; stabilize function với useCallback để tránh re-render không cần thiết. select là transform function chạy sau khi queryFn trả về data, trước khi component nhận — không ảnh hưởng gì đến cache (cache vẫn lưu raw data).
Re-render optimization: React Query memoize select result — component chỉ re-render khi select(data) trả về giá trị khác (dùng structural equality).
Ví dụ: nhiều components dùng cùng query ['todos'] nhưng mỗi component có select khác nhau — select: d => d.filter(t => !t.done) (incomplete) và select: d => d.filter(t => t.done) (complete) — component nào chỉ re-render khi phần data của nó thay đổi.
Patterns thực tế:
- Pick fields:
select: d => ({ name: d.name, avatar: d.avatar })giảm re-render khi các field khác thay đổi. - Sort/filter:
select: d => [...d].sort((a,b) => b.date - a.date). - Normalize:
select: d => Object.fromEntries(d.map(i => [i.id, i]))
Pitfall: nếu select là inline arrow function, memoization không hoạt động vì function reference thay đổi mỗi render — stabilize bằng useCallback hoặc định nghĩa outside component.
Parallel queries là kỹ thuật fetch nhiều API endpoints cùng lúc thay vì tuần tự (waterfall).
- Cách 1: gọi nhiều
useQueryhooks trong cùng component — React Query tự động chạy chúng parallel. - Cách 2:
useQuerieshook nhận mảng query configs, hữu ích khi số lượng queries dynamic (ví dụ fetch details cho danh sách product IDs). - Kết quả trả về mảng objects, mỗi object giống useQuery result.
Ví dụ: dashboard cần user info + notifications + stats → 3 useQuery hooks chạy song song, tổng thời gian = query chậm nhất (thay vì cộng dồn).
- Lưu ý: React Suspense mode sẽ waterfall nếu mỗi query ở component khác nhau — dùng
useSuspenseQueriesđể parallel trong Suspense.
React Query cung cấp signal (AbortSignal) qua queryFn context — pass signal vào fetch để enable cancellation: queryFn: ({ signal }) => fetch(/api/users/${id}, { signal }).
Khi component unmount hoặc queryKey thay đổi, React Query tự động abort signal → browser hủy pending request.
Với axios: queryFn: ({ signal }) => axios.get(/api/users/${id}, { signal }) — axios v0.22+ support AbortSignal trực tiếp.
Custom fetch wrapper: async function fetchWithCancel(url, { signal }) { const res = await fetch(url, { signal }); if (!res.ok) throw new Error(res.statusText); return res.json() }.
Cancellation xảy ra khi:
- component unmount trước khi fetch xong (user navigate đi),
- queryKey thay đổi nhanh (search input) — request cũ bị cancel khi key mới,
queryClient.cancelQueries()được gọi manual (thường trong optimistic update onMutate)
Pitfall: nếu queryFn không dùng signal, query vẫn complete dù component unmount — data được cache nhưng component đã gone, React Query tự discard result.
Không dùng signal gây lãng phí bandwidth nhưng không gây bug.
keepPreviousData (v4) / placeholderData: prev => prev (v5) giữ data trang trước khi paginate — tránh flash trắng; kết hợp với prefetch trang tiếp theo cho UX hoàn hảo. keepPreviousData là v3/v4 API, trong v5 thay bằng placeholderData: (previousData) => previousData (identity function).
- Khi queryKey thay đổi (page 1 → page 2), thay vì show loading + clear data cũ, React Query giữ nguyên data của page 1 trong khi fetch page 2 —
isPlaceholderData: truecho biết đang hiển thị data cũ. - UX impact lớn với pagination: user thấy page 1 vẫn hiển thị (có thể mờ đi để indicate loading), page 2 load xong thì replace — không có flash trắng.
- Kết hợp với prefetch để UX hoàn hảo:
useEffect(() => { if (!isPlaceholderData && hasNextPage) { queryClient.prefetchQuery({ queryKey: ['todos', page + 1], queryFn: () => fetchTodos(page + 1) }) } }, [data, page, isPlaceholderData, queryClient]). - Cũng hữu ích với filter/search: khi user gõ query search, giữ previous results trong khi tìm kiếm mới → không flash empty.
Pitfall: nếu dùng select transform, previousData cũng đã qua select — types match đúng.
enabled: !!dependency là pattern chuẩn cho dependent queries — query không fetch cho đến khi dependency có giá trị; enabled: false + refetch() cho lazy queries triggered by user action. enabled option kiểm soát khi nào query được phép chạy — là boolean hoặc function trả về boolean.
Khi enabled: false, query ở trạng thái 'disabled': không auto-fetch, status là 'pending' với fetchStatus: 'idle' (không phải loading).
Khi enabled chuyển từ false sang true, query tự động fetch ngay.
Use cases:
- Dependent queries — chỉ fetch khi dependency sẵn sàng:
enabled: !!userId && !!orgId. - Lazy queries — chỉ fetch khi user trigger:
const [enabled, setEnabled] = useState(false)→ button onClick:setEnabled(true). - Conditional skip — skip query dựa trên route hay feature flag.
- Pause queries khi offline:
enabled: isOnline
Pattern với TypeScript: khi enabled: !!userId, TypeScript vẫn thấy userId có thể undefined trong queryFn — workaround: queryFn: () => fetchUser(userId!) hoặc queryFn: () => fetchUser(userId as string).
Pitfall: enabled: someExpensiveCheck() gọi mỗi render — wrap trong useMemo nếu check tốn kém. isLoading với disabled query: isLoading = true nếu enabled=false và chưa có data (status=pending) — check fetchStatus === 'idle' để phân biệt disabled vs loading.
Server state thuộc về React Query; client UI state thuộc về Zustand/Redux — trộn lẫn dẫn đến duplication.
- RTK Query là data fetching và caching layer tích hợp sẵn trong Redux Toolkit, cạnh tranh trực tiếp với React Query/SWR.
- Ưu điểm so với fetch thủ công: tự động quản lý loading/error/success states (không cần viết 3 cases trong reducer), cache data theo endpoint + params, tự động refetch khi args thay đổi, deduplication (10 components cùng gọi
useGetUserQuery(1)chỉ 1 request). - Cache invalidation qua tag system:
providesTags: ['Post']ở query vàinvalidatesTags: ['Post']ở mutation — khi mutation xong, tất cả queries với tag 'Post' tự động refetch. - So với React Query: RTK Query tích hợp sẵn Redux store (không cần thêm thư viện), nhưng React Query có API linh hoạt hơn và không bắt buộc dùng Redux.
Pitfall: nếu project đã dùng React Query, thêm RTK Query tạo 2 cache layers trùng lặp — chọn một.
createApi là core API của RTK Query, định nghĩa toàn bộ endpoints cho một base URL.
- Cấu hình
baseQuery(thườngfetchBaseQuery({ baseUrl: '/api' })) xác định cách gọi API, có thể custom để thêm auth headers hay handle token refresh. - Mỗi endpoint là query (GET) hoặc mutation (POST/PUT/DELETE), tự động tạo hooks:
getUsers→useGetUsersQuery(),addUser→useAddUserMutation(). - Cache invalidation dùng tag system:
tagTypes: ['User'], queryprovidesTags: ['User'], mutationinvalidatesTags: ['User']— khi add user xong, danh sách users tự refetch.
Ví dụ: endpoints: (builder) => ({ getUsers: builder.query({ query: () => '/users', providesTags: ['User'] }), addUser: builder.mutation({ query: (body) => ({ url: '/users', method: 'POST', body }), invalidatesTags: ['User'] }) }).
Pitfall: chỉ nên có MỘT createApi per base URL, dùng injectEndpoints để code-split endpoints ra nhiều files.
Redux DevTools là browser extension record toàn bộ action history theo timeline — mỗi action được log kèm payload, prev state và next state diff.
Time-travel mechanism: DevTools lưu snapshot state sau mỗi action; bạn có thể 'jump' đến bất kỳ snapshot bằng click vào action trong history list, app render lại đúng state tại thời điểm đó mà không cần reload hay reproduce thủ công.
Tính năng chính:
- Action log với filter theo type,
- State diff highlighting thay đổi màu xanh/đỏ,
- Replay — chạy lại từng action từ đầu,
- Skip action — loại bỏ action cụ thể để test what-if,
- Import/export state JSON để share bug reproduction,
- Dispatcher panel để dispatch action thủ công
Workflow thực tế: user báo bug ở màn hình checkout → export state JSON từ session của họ → import vào DevTools của developer → jump từng action để tìm chính xác điểm gây ra bug.
Yêu cầu quan trọng: state và action payload PHẢI serializable — không được là Date, Function, Promise hay class instance — vi phạm khiến DevTools crash.
RTK tích hợp sẵn serializability check ở development mode để cảnh báo ngay khi có non-serializable data.
Framework decision: DÙNG Redux khi
- shared state cần ở nhiều component cách xa nhau — ví dụ user auth info cần ở Header, Sidebar, Feed và Modal đồng thời;
- state logic phức tạp với nhiều actions tương tác — shopping cart có coupon, discount, tax, shipping tính toán lẫn nhau;
- cần action history/audit trail — ứng dụng financial, collaborative editing cần biết ai làm gì lúc nào;
- team đã quen Redux với codebase lớn — migration cost cao hơn benefit. KHÔNG dùng Redux khi:
- chủ yếu là server state — dùng React Query thay, Redux cho server data là over-engineering;
- state chỉ local trong 1-2 component — useState là đủ;
- app nhỏ hoặc team mới — learning curve Redux + RTK không cần thiết
Alternatives landscape: Zustand cho global client state đơn giản (~8KB, zero boilerplate), Jotai cho atomic fine-grained state, React Query cho server state (thay thế 80% Redux use cases), Context API cho low-frequency updates như theme/locale.
Rule of thumb: nếu đang viết Redux chủ yếu để cache API data, hãy dùng React Query — Redux nên chỉ còn cho UI state thật sự global như auth, cart, notification preferences.
Slice pattern tổ chức Zustand store lớn thành modules độc lập — mỗi slice là StateCreator function, combine bằng spread trong create(); tên state không được trùng giữa các slices.
- Slice pattern giúp tổ chức store lớn thành các modules độc lập.
- Mỗi slice là một
StateCreatorfunction:const createCartSlice = (set, get) => ({ items: [], addItem: (item) => set(s => ({ items: [...s.items, item] })), clearCart: () => set({ items: [] }) }). - Tương tự:
const createUserSlice = (set) => ({ user: null, setUser: (u) => set({ user: u }), logout: () => set({ user: null }) }). - Combine trong create:
const useBoundStore = create((...a) => ({ ...createCartSlice(...a), ...createUserSlice(...a) })). - Với TypeScript: dùng
StateCreator<BoundStore, [], [], CartSlice>để type đúng — BoundStore là type của toàn bộ combined store, CartSlice là type của slice này. - Slice có thể cross-reference nhau qua
get():const createOrderSlice = (set, get) => ({ checkout: () => { const { items } = get(); const { user } = get(); return api.order(user.id, items) } }).
Pitfall: tên state/action không được trùng giữa các slices — spread object sẽ overwrite.
Zustand v4+ dùng curried create với generic create<StoreType>() — thiếu dấu () thứ hai TypeScript sẽ không infer đúng.
- Pattern chuẩn với TypeScript: định nghĩa type cho toàn bộ store trước, rồi pass vào generic.
- Zustand v4+ dùng curried create:
create<StoreType>()((set, get) => ({...}))— dấu()thứ hai bắt buộc để TypeScript infer đúng.
Ví dụ: interface CartStore { items: CartItem[]; total: number; addItem: (item: CartItem) => void; clearCart: () => void } → const useCartStore = create<CartStore>()((set, get) => ({ items: [], total: 0, addItem: (item) => set(s => ({ items: [...s.items, item], total: s.total + item.price })), clearCart: () => set({ items: [], total: 0 }) })).
- Với slices dùng
StateCreatortype:type CartSlice = { items: CartItem[]; addItem: (item: CartItem) => void }; const createCartSlice: StateCreator<BoundStore, [], [], CartSlice> = (set) => ({...}). - Infer store type từ store:
type StoreState = ReturnType<typeof useStore.getState>.
Pitfall: không nên dùng create mà không có generic — mất type safety và set nhận any.
Nuanced comparison: Developer Experience — Zustand thắng rõ rệt: zero config, zero boilerplate, học trong 30 phút.
- RTK phức tạp hơn nhưng có structure rõ ràng hơn.
- Ecosystem: RTK thắng — có RTK Query (data fetching + caching tích hợp), middleware ecosystem phong phú, tài liệu extensive.
- DevTools: cả hai đều kết nối Redux DevTools Extension, nhưng RTK log actions chi tiết hơn (mỗi action có type rõ ràng), Zustand cần đặt tên manual trong devtools middleware.
- Team size: Zustand tốt cho team nhỏ (flexibility, speed), RTK tốt hơn cho team lớn (enforced patterns, code reviews dễ hơn khi có conventions).
- Learning curve: Zustand ~2h, RTK ~2 ngày (createSlice, createAsyncThunk, RTK Query, tags system).
- Middleware: RTK có thunk/saga/logger ecosystem rộng hơn, Zustand middleware nhẹ nhàng hơn.
- Kết luận thực tế: nếu không cần RTK Query và team < 5 người → Zustand.
- Nếu cần data fetching layer tích hợp Redux hoặc team lớn cần consistency → RTK.
Prefetch on hover (client) hoặc trong Server Component (server) để data sẵn sàng trước khi user cần — loại bỏ loading state hoàn toàn.
- Prefetching là kỹ thuật fetch data trước khi user cần, giúp loại bỏ loading state.
- Client-side pattern: prefetch on hover để khi user click vào link thì data đã sẵn sàng —
const handleMouseEnter = () => queryClient.prefetchQuery({ queryKey: ['post', id], queryFn: () => fetchPost(id), staleTime: 30_000 }). - Server-side với Next.js App Router:
const queryClient = new QueryClient(); await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts }); const dehydratedState = dehydrate(queryClient); return <HydrationBoundary state={dehydratedState}><PostList /></HydrationBoundary>— client nhận data đã fetch sẵn, không có loading flash.prefetchInfiniteQuerycho infinite scroll pages: pre-load trang đầu tiên trên server, user thấy content ngay. - Prefetch trong route handler (Next.js): đặt prefetch logic trong layout.tsx để data sẵn sàng trước khi page.tsx render.
Pitfall: prefetchQuery không throw error (khác fetchQuery), query silently fails — luôn check error state ở component level.
useInfiniteQuery quản lý nhiều pages trong 1 query — getNextPageParam quyết định cursor page tiếp theo, fetchNextPage() load thêm khi user scroll tới cuối. useInfiniteQuery là hook chuyên biệt cho infinite scroll và load-more pagination — thay vì fetch 1 page, nó quản lý nhiều pages trong 1 query.
- Cấu hình:
queryFnnhậnpageParam(cursor hoặc page number),getNextPageParamtrả về param cho page tiếp theo (return undefined khi hết data). - Kết quả:
data.pageslà mảng chứa data từng page,data.pageParamslưu params đã dùng. - Gọi
fetchNextPage()khi user scroll tới cuối (dùng IntersectionObserver).
Ví dụ: social feed, product listing, chat history.
- Có thêm
fetchPreviousPage()cho bi-directional infinite scroll.
Dependent query dùng enabled: !!prerequisite để chỉ chạy khi dependency sẵn sàng — tránh waterfall bằng cách chạy parallel khi queries độc lập.
- Dependent query (hay serial query) là query mà việc thực thi phụ thuộc vào kết quả của query khác.
- Dùng
enabledoption để kiểm soát: ví dụ fetch user trước, rồi mới fetch posts của user đó —useQuery({ queryKey: ['posts', userId], queryFn: () => fetchPosts(userId), enabled: !!userId }). - Khi userId chưa có (query user chưa xong), enabled = false nên posts query không chạy.
- Khi userId có giá trị, enabled = true → tự động fetch.
- React Query handle loading states đúng cho cả chain: component thấy isLoading = true cho tới khi query cuối cùng hoàn thành.
- Tránh lạm dụng dependent queries vì tạo waterfall — nếu queries độc lập, chạy parallel tốt hơn.
Optimistic update flow đầy đủ: khi user thực hiện action, update UI ngay lập tức trước khi API confirm, rollback nếu API fail.
- Implementation:
onMutate: async (newTodo) => { await queryClient.cancelQueries({ queryKey: ['todos'] }); const snapshot = queryClient.getQueryData(['todos']); queryClient.setQueryData(['todos'], (old) => [...old, { ...newTodo, id: 'temp-' + Date.now() }]); return { snapshot } }.onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context.snapshot) }.onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }) }. - Quan trọng:
cancelQueriestrước khi optimistic update — tránh race condition khi background refetch overwrite optimistic state. - Context pattern: onMutate return value trở thành
contexttrong onError/onSettled. - Khi nào dùng optimistic vs simple invalidation: optimistic cho actions user expect instant feedback (like, follow, toggle), simple invalidation cho actions phức tạp hơn (payment, create order) vì rollback UX tệ hơn là loading nhỏ.
Pitfall: optimistic ID ('temp-123') cần được replace bằng real ID sau khi server confirm — onSettled invalidate để refetch với real data.
Next.js App Router pattern với React Query: Server Components fetch data và dehydrate cache, Client Components nhận hydrated cache — không có loading flash.
Pattern đầy đủ:
- Tạo
getQueryClient()factory vớicache()từ React để mỗi request server có QueryClient riêng (tránh data leak giữa users). - Server Component:
const queryClient = getQueryClient(); await queryClient.prefetchQuery({ queryKey: ['posts'], queryFn: fetchPosts }); return <HydrationBoundary state={dehydrate(queryClient)}><PostList /></HydrationBoundary>. - Client Component dùng
useQuerybình thường — nhận data từ hydrated cache ngay, không request thêm nếu data còn fresh
Streaming hydration: có thể dùng với Suspense để stream từng phần page.
RSC considerations: không thể dùng React Query hooks trong RSC (chỉ là async functions) — hooks chỉ trong Client Components.
Nên prefetch ở server những gì quan trọng cho initial render, lazy load phần ít quan trọng hơn ở client.
Pitfall: nếu không dùng cache() cho QueryClient factory, server-side QueryClient sẽ được share giữa requests → data leaking.
Async read atom: const userAtom = atom(async (get) => { const id = get(userIdAtom); return await fetchUser(id); }) — component dùng useAtomValue(userAtom) sẽ tự suspend trong khi fetch (cần bọc <Suspense>).
- Đây là tích hợp với React Suspense tự nhiên nhất trong các state libs.
loadable(userAtom)từjotai/utilstrả về{ state: 'loading'|'hasData'|'hasError', data, error }— tránh Suspense khi cần kiểm soát loading UI thủ công.atomWithQuerytừjotai-tanstack-querytích hợp TanStack Query vào atom:const postsAtom = atomWithQuery(() => ({ queryKey: ['posts'], queryFn: fetchPosts })).
Pitfall: async atom re-fetch mỗi khi dependency atom thay đổi, không có built-in stale-time như React Query — dùng atomWithQuery nếu cần caching strategy phức tạp.
Bundle size (minified+gzipped): Jotai ~3KB, Zustand ~1KB (core) đến ~8KB với middleware.
- Re-render characteristics: cả hai đều fine-grained — chỉ re-render component subscribe đúng atom/slice thay đổi, khác Context API re-render cả cây.
- Jotai vs Zustand về performance: Jotai atomic model tốt cho graph-shaped state với nhiều derived values (mỗi atom có dependency tracking riêng), Zustand tốt hơn khi cần batch updates và ít derived state vì single-store overhead thấp.
- Tree-shaking: cả hai đều tree-shakeable tốt (ESM-first).
- Memory: Jotai dùng WeakMap nên atoms tự GC khi không còn reference, Zustand giữ store reference khi còn subscriber.
- Benchmark thực tế (js-framework-benchmark): hai thư viện tương đương về raw render speed, bottleneck thường là React reconciliation chứ không phải state library.
- Khuyến nghị: Zustand cho simplicity và tiny bundle, Jotai cho fine-grained atomic state với nhiều derived values.
Jotai phù hợp cho state có cấu trúc graph với nhiều derived values; Zustand/Redux phù hợp khi cần single source of truth serialize được và time-travel debug.
Decision matrix — dùng Atomic (Jotai) khi:
- State có cấu trúc graph, nhiều derived values từ base atoms (ví dụ: cart total, filtered lists, computed stats đều derive từ cùng nguồn);
- Cần fine-grained updates — 1000 items list, mỗi item là 1 atom, chỉ item thay đổi mới re-render;
- State không nhất thiết phải centralized — features khác nhau sở hữu atoms riêng;
- Cần co-locate state với components sử dụng nó
Dùng Redux/Zustand khi: cần single source of truth có thể serialize/rehydrate, cần time-travel debugging, team quen với flux pattern, cần middleware pipeline (logging, side effects).
Real use cases Atomic: collaborative editor (mỗi doc element là atom), form builder (mỗi field là atom), dashboard với nhiều widgets độc lập.
Pitfall: atomic state khó debug hơn khi dependencies phức tạp — không có Redux DevTools tương đương để xem toàn bộ state graph.
Query Filters là pattern matching system cho phép target nhóm queries khi thao tác hàng loạt.
- Dùng trong
invalidateQueries,refetchQueries,removeQueries,cancelQueries. - Filter options:
queryKey(fuzzy match —['todos']match cả['todos', 1]),type('active' = đang có subscriber, 'inactive' = cached nhưng không ai dùng, 'all'),stale(true/false),fetchStatus('fetching'/'paused'/'idle').
Ví dụ thực tế: user logout → queryClient.removeQueries({ type: 'all' }) xóa toàn bộ cache.
- Hoặc: mutation thành công →
invalidateQueries({ queryKey: ['todos'], type: 'active' })chỉ refetch todos đang hiển thị.
useSuspenseQuery tích hợp React Suspense — thay vì return isLoading/isError, nó throw Promise (cho Suspense boundary bắt) hoặc throw Error (cho Error Boundary bắt).
- Kết quả:
dataluôn defined (TypeScript happy), không cần if/else cho loading/error states. - Code gọn hơn: component chỉ chứa happy path.
- Suspense boundary ở parent xử lý loading, ErrorBoundary xử lý error.
- Khi nào dùng: project đã adopt Suspense pattern, muốn tách loading UI khỏi component logic.
- Khi nào KHÔNG dùng: cần kiểm soát loading state chi tiết, cần show partial data trong khi fetch.
- Lưu ý: nhiều useSuspenseQuery trong 1 component sẽ waterfall — dùng
useSuspenseQueriesđể parallel.
persistQueryClient cho phép serialize toàn bộ React Query cache vào storage, restore khi user reload → data hiển thị ngay lập tức không cần fetch lại.
- Setup:
import { persistQueryClient } from '@tanstack/react-query-persist-client'; import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'.const persister = createSyncStoragePersister({ storage: window.localStorage }); persistQueryClient({ queryClient, persister, maxAge: 1000 60 60 * 24 })— maxAge là thời gian data được phép restore (default 24h). - Với React Native dùng
createAsyncStoragePersistercho AsyncStorage. - IndexedDB persister cho data lớn hơn localStorage limit (5MB).
- Khi app load: nếu persisted data còn trong maxAge, hydrate vào cache ngay → queries show cached data, background refetch nếu stale.
busteroption để invalidate toàn bộ persisted cache khi deploy version mới (thay đổi cấu trúc data).
Pitfall: không persist sensitive data (tokens, personal info) vì localStorage không mã hóa.
- Dùng
dehydrateOptions.shouldDehydrateQueryđể filter queries nào được persist.
useMutation cung cấp 4 lifecycle callbacks, có thể set ở cả mutation level và per-mutate() call level (per-call override mutation level). onMutate(variables): chạy synchronously trước khi mutation request gửi đi — dùng để optimistic update cache, return value trở thành context trong onError/onSettled.
- Nếu return Promise, mutation chờ resolve.
onSuccess(data, variables, context): chạy khi API thành công với response data — thường dùng để invalidate related queries:queryClient.invalidateQueries({ queryKey: ['todos'] })hoặc setQueryData với response.onError(error, variables, context): chạy khi API fail — context từ onMutate dùng để rollback:queryClient.setQueryData(['todos'], context.previousTodos).onSettled(data, error, variables, context): luôn chạy sau onSuccess hoặc onError (như finally) — an toàn để invalidate queries ở đây vì chạy cả khi success lẫn error. - Thứ tự: onMutate → [mutationFn] → onSuccess/onError → onSettled.
Pitfall: nếu cả mutation-level và call-level đều có onSuccess, cả hai đều chạy — mutation-level chạy trước.
React Query DevTools là công cụ không thể thiếu — visualize cache state real-time, inspect data/staleTime/subscribers, manual trigger refetch/invalidate; tự động excluded khỏi production build.
- React Query DevTools (
@tanstack/react-query-devtools) là công cụ debug chuyên dụng, hiển thị floating panel trong browser. - Tính năng: xem tất cả queries và trạng thái real-time (fresh/stale/inactive/fetching), inspect cached data dạng JSON tree, xem query timing (khi nào fetch, bao lâu), manual trigger refetch/invalidate/remove/reset từng query, xem subscribers count.
- Setup:
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'→ đặt<ReactQueryDevtools initialIsOpen={false} />bên trong QueryClientProvider. - Tự động ẩn trong production build (tree-shaken).
- Rất hữu ích khi debug cache issues: tại sao data stale, query nào đang fetch, cache hit hay miss.
- Là 1 trong những lý do developer chọn React Query — visibility vào cache layer mà Redux DevTools không cho.
React Query giải quyết race conditions tự động: auto-cancel request cũ khi queryKey thay đổi (cần pass signal vào fetch), deduplication (nhiều components cùng query chỉ 1 request), luôn lấy result mới nhất.
React Query giải quyết race conditions tự động: Race condition xảy ra khi nhiều requests cùng lúc và response muộn hơn override response mới hơn — vấn đề kinh điển với useEffect + fetch.
React Query giải quyết ở nhiều tầng:
- Cancellation: khi queryKey thay đổi (ví dụ search input), React Query abort request cũ nếu queryFn nhận và dùng signal —
queryFn: ({ signal }) => fetch(url, { signal }). - Deduplication: nếu query đang fetch và component khác request cùng queryKey, React Query không gửi thêm request mà share kết quả — chỉ 1 request.
- Strict mode safety: React Query không commit kết quả của request đã bị replace bởi request mới hơn.
enabledpattern cho dependent queries:enabled: !!userIdđảm bảo không fetch với stale userId
Comparison với useEffect: với useEffect bạn phải manually track và cancel với cleanup function return () => { cancelled = true } hoặc AbortController — dễ leak và dễ có bugs.
React Query làm tất cả điều này tự động miễn là queryFn sử dụng signal.
SWR cho app đơn giản read-heavy với Next.js (~4KB, minimal API); React Query cho app phức tạp cần mutations, optimistic updates, DevTools, granular cache control (~13KB nhưng feature-rich hơn 3x).
- So sánh chi tiết React Query (TanStack Query v5) vs SWR v2: Bundle size — SWR ~4KB, React Query ~13KB (nhưng features nhiều hơn 3x).
- API philosophy — SWR ưu tiên simplicity và convention, React Query ưu tiên control và flexibility.
- Mutations: React Query có
useMutationhook đầy đủ (onMutate, onSuccess, onError, onSettled, optimistic updates), SWR không có mutation hook — thường dùng thư viện khác hoặc tự viết. - DevTools: React Query có DevTools panel chi tiết, SWR không có DevTools chính thức.
- Infinite queries: cả hai có, nhưng React Query có
getNextPageParam+getPreviousPageParamlinh hoạt hơn. - Prefetching + SSR: React Query có dehydrate/HydrationBoundary pattern rõ ràng hơn, SWR có
fallbackprop đơn giản hơn. - Cache key: React Query dùng array (type-safe, fuzzy match), SWR dùng string/function.
- Offline support: React Query có
networkModeoption, SWR basic. - Kết luận: chọn SWR khi app chủ yếu read-only data fetching, team muốn đơn giản, bundle size quan trọng, dùng nhiều với Next.js pages router.
- Chọn React Query khi cần mutations phức tạp, optimistic updates, DevTools cho debugging, team cần full-featured solution.