Luyện Phỏng Vấn IT — 2000+ Câu Hỏi Phỏng Vấn IT Có Đáp Án 2026

Danh mục

State Management iconState 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.

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:

  1. 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).
  2. 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.
  3. 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
  4. 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
  5. 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 } }.

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ặc createStore() (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 qua useSelector (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.

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 create trả 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 })).

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.

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/useMutation bê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.

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:

  1. quá nhiều boilerplate (actions, action types, reducers, combineReducers riêng biệt) → createSlice gộp tất cả vào một chỗ.
  2. Immutable update phức tạp ({...state, nested: {...state.nested, value: newVal}}) → Immer bên trong cho phép viết state.nested.value = newVal.
  3. Async logic verbose → createAsyncThunk xử 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 increment trong slice tên 'counter' tạo ra action type counter/increment và action creator counterSlice.actions.increment().
  • Bên trong dùng Immer nên viết state.value += 1 thay 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) } }).

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.

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 → useSelector thấy reference khác → component re-render dù data không đổi. createSelector từ 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.

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.

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.

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:

  1. Partial update với set: increment: () => set(s => ({ count: s.count + 1 })).
  2. 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] })) }.
  3. 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:

  1. Tách thành 2 selectors riêng: const a = useStore(s => s.a); const b = useStore(s => s.b).
  2. Dùng useShallow hook từ 'zustand/react/shallow' để shallow compare object/array: const { a, b } = useStore(useShallow(s => ({ a: s.a, b: s.b }))).
  3. Dùng createSelectors pattern để 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:

  1. 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;
  2. immer — cho phép mutate state trực tiếp trong set: set(s => { s.items.push(item) }) thay vì phải spread;
  3. persist — lưu/khôi phục state vào storage;
  4. 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 persist từ '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. onRehydrateStorage là 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:

  1. onMutate: cancel ongoing queries, snapshot data cũ, set cache mới ngay → UI cập nhật tức thì.
  2. onError: nhận snapshot từ onMutate context, rollback cache.
  3. 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.

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 onError global — dùng QueryCache listener: 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:

  1. data ít thay đổi (config, static content),
  2. refetch gây UX tệ (reset scroll, flash loading),
  3. 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: true khiến query throw error lên Error Boundary thay vì return isError.
  • Hoặc dùng useSuspenseQuery — tự động throw lên Error Boundary. error.message vs error.response.data: với fetch API, HTTP errors không tự throw — phải check if (!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:

  1. Include ALL dependencies — bất cứ biến nào dùng trong queryFn phải có trong key: ['todos', userId, { status, page, sort }].
  2. Dùng array, không dùng string thuần — array cho phép fuzzy matching khi invalidate.
  3. 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 key string duy nhất cho mỗi atom (Recoil bắt buộc, dễ gây conflict trong large codebase), không cần RecoilRoot wrapper (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ùng useAtom(countAtom) trả về [value, setValue].
  • Derived read-only: const doubleAtom = atom(get => get(countAtom) * 2) — dùng useAtomValue.
  • Derived read-write: atom(get => get(baseAtom), (get, set, newVal) => set(baseAtom, newVal * 2)). atomWithStorage('key', defaultVal) từ jotai/utils tự đồ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:

  1. Testing — <Provider store={createStore()}> tạo isolated store mỗi test, tránh state leak giữa tests;
  2. Micro-frontend — mỗi app widget có store riêng;
  3. 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.

js
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ế:

  1. Pick fields: select: d => ({ name: d.name, avatar: d.avatar }) giảm re-render khi các field khác thay đổi.
  2. Sort/filter: select: d => [...d].sort((a,b) => b.date - a.date).
  3. 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 useQuery hooks trong cùng component — React Query tự động chạy chúng parallel.
  • Cách 2: useQueries hook 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).

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:

  1. component unmount trước khi fetch xong (user navigate đi),
  2. queryKey thay đổi nhanh (search input) — request cũ bị cancel khi key mới,
  3. 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: true cho 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:

  1. Dependent queries — chỉ fetch khi dependency sẵn sàng: enabled: !!userId && !!orgId.
  2. Lazy queries — chỉ fetch khi user trigger: const [enabled, setEnabled] = useState(false) → button onClick: setEnabled(true).
  3. Conditional skip — skip query dựa trên route hay feature flag.
  4. 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.