Quản lý state trong React đơn giản: tách dữ liệu server khỏi state client, tuân thủ vài quy tắc, và nhận diện sớm dấu hiệu phức tạp tăng lên.

State là bất kỳ dữ liệu nào có thể thay đổi khi app của bạn chạy. Điều đó bao gồm những gì bạn thấy (một modal đang mở), thứ bạn đang chỉnh sửa (bản nháp form), và dữ liệu bạn fetch (danh sách project). Vấn đề là tất cả đều được gọi là state, dù chúng hành xử rất khác nhau.
Hầu hết các app lộn xộn đều vướng cùng một cách: quá nhiều loại state bị trộn ở cùng một chỗ. Một component kết thúc bằng việc chứa dữ liệu server, flag UI, bản nháp form, và các giá trị dẫn xuất, rồi cố gắng giữ chúng khớp bằng các effect. Chẳng mấy chốc, bạn không thể trả lời những câu hỏi đơn giản như "giá trị này đến từ đâu?" hoặc "ai cập nhật nó?" mà không mò qua nhiều file.
Các app React được tạo tự động thường trôi vào tình trạng này nhanh hơn vì dễ chấp nhận phiên bản đầu tiên chạy được. Bạn thêm màn mới, copy pattern, vá bug bằng một useEffect nữa, và giờ bạn có hai nguồn chân lý. Nếu generator hoặc team đổi hướng giữa chừng (state cục bộ ở đây, global store ở kia), codebase sẽ tích lũy pattern thay vì xây trên một nền tảng.
Mục tiêu là nhàm chán: ít loại state hơn, ít nơi cần tìm hơn. Khi có một chỗ rõ ràng cho dữ liệu server và một chỗ rõ ràng cho state chỉ dành cho UI, bug nhỏ lại và thay đổi không còn cảm thấy rủi ro.
"Giữ cho nó nhàm chán" nghĩa là bạn tuân theo vài quy tắc:
Một ví dụ cụ thể: nếu danh sách user đến từ backend, xử lý nó như server state và fetch ở nơi dùng. Nếu selectedUserId chỉ tồn tại để điều khiển một panel chi tiết, giữ nó như state UI nhỏ gần panel đó. Trộn hai thứ này là nơi bắt đầu của phức tạp.
Hầu hết vấn đề state trong React bắt đầu từ một sự nhầm lẫn: xử lý dữ liệu server như state UI. Tách chúng sớm và quản lý state sẽ bình tĩnh, ngay cả khi app của bạn lớn lên.
Server state thuộc về backend: users, orders, tasks, permissions, prices, feature flags. Nó có thể thay đổi mà app của bạn không làm gì (tab khác cập nhật, admin sửa, job chạy, dữ liệu hết hạn). Vì nó được chia sẻ và có thể thay đổi, bạn cần fetch, cache, refetch và xử lý lỗi.
Client state là thứ chỉ UI của bạn quan tâm ngay bây giờ: modal nào đang mở, tab nào được chọn, toggle bộ lọc, thứ tự sắp xếp, sidebar thu gọn, bản nháp truy vấn tìm kiếm. Nếu bạn đóng tab, mất nó cũng ổn.
Một bài kiểm tra nhanh là: "Tôi có thể refresh trang và xây lại cái này từ server không?"
Còn có derived state, giúp bạn tránh tạo state thừa ngay từ đầu. Đó là giá trị bạn có thể tính từ các giá trị khác, nên bạn không lưu nó. Danh sách lọc, tổng, isFormValid, và "hiển thị empty state" thường thuộc loại này.
Ví dụ: bạn fetch danh sách projects (server state). Bộ lọc được chọn và flag dialog "New project" là client state. Danh sách hiển thị sau khi lọc là derived state. Nếu bạn lưu danh sách hiển thị riêng, nó sẽ lệch và bạn sẽ đi tìm lỗi "tại sao nó stale?".
Sự phân chia này có ích khi một công cụ như Koder.ai sinh màn hình nhanh: giữ dữ liệu backend trong một lớp fetch, giữ lựa chọn UI gần component, và tránh lưu các giá trị tính toán.
State trở nên đau đầu khi một mẩu dữ liệu có hai chủ sở hữu. Cách nhanh nhất để giữ mọi thứ đơn giản là quyết ai sở hữu gì và tuân thủ nó.
Ví dụ: bạn fetch danh sách users và hiển thị chi tiết khi một user được chọn. Một sai lầm phổ biến là lưu toàn bộ object user đang chọn trong state. Hãy lưu selectedUserId thay vì. Giữ danh sách trong cache server. View chi tiết lookup user theo ID, vậy refetch sẽ cập nhật UI mà không cần code sync thêm.
Trong các app React được tạo tự động, cũng dễ chấp nhận state "hữu ích" được tạo ra sao chép dữ liệu server. Khi thấy code làm fetch -> setState -> edit -> refetch, dừng lại. Đó thường là dấu hiệu bạn đang xây một cơ sở dữ liệu thứ hai trong trình duyệt.
Server state là bất cứ thứ gì sống trên backend: danh sách, trang chi tiết, kết quả tìm kiếm, permissions, counts. Cách nhàm chán là chọn một công cụ cho nó và dùng thống nhất. Với nhiều app React, TanStack Query là đủ.
Mục tiêu rõ ràng: component yêu cầu dữ liệu, hiển thị loading và lỗi, và không quan tâm có bao nhiêu cuộc gọi fetch diễn ra bên dưới. Điều này quan trọng trong app được tạo tự động vì những khác biệt nhỏ sẽ nhân lên nhanh khi thêm màn mới.
Xem query key như một hệ thống đặt tên, không phải thứ lãng phí. Giữ chúng nhất quán: keys mảng ổn định, chỉ bao gồm input thay đổi kết quả (filters, page, sort), và ưu tiên vài dạng dự đoán được hơn là nhiều key rời rạc. Nhiều team đặt việc xây key vào helper nhỏ để mọi màn dùng cùng quy tắc.
Với nghiệp vụ viết dữ liệu, dùng mutations với xử lý success rõ ràng. Một mutation nên trả lời hai câu hỏi: gì đã thay đổi, và UI nên làm gì tiếp theo?
Ví dụ: bạn tạo task mới. Khi thành công, hoặc invalidate query danh sách tasks (để nó load lại một lần) hoặc cập nhật cache mục tiêu (thêm task mới vào danh sách cache). Chọn một cách trên mỗi feature và giữ thống nhất.
Nếu bạn có xu hướng thêm nhiều gọi refetch ở nhiều nơi "cho chắc", hãy chọn một động thái nhàm chán:
Client state là thứ trình duyệt sở hữu: flag sidebar mở, hàng được chọn, text lọc, bản nháp trước khi lưu. Giữ nó gần nơi dùng và nó thường dễ quản lý.
Bắt đầu nhỏ: useState ở component gần nhất. Khi bạn sinh màn (ví dụ với Koder.ai), dễ bị cám dỗ đẩy mọi thứ vào global store "phòng khi". Đó là cách biến store thành thứ không ai hiểu.
Chỉ di chuyển state lên khi bạn có thể đặt tên cho vấn đề chia sẻ.
Ví dụ: một table với panel chi tiết có thể giữ selectedRowId trong component table. Nếu toolbar ở phần khác của trang cũng cần, nâng lên component trang. Nếu một route khác (như bulk edit) cần, khi đó store nhỏ mới hợp lý.
Nếu bạn dùng store (Zustand hoặc tương tự), giữ nó tập trung vào một nhiệm vụ. Lưu "cái gì" (selected IDs, filters), không lưu "kết quả" (danh sách đã sort) mà bạn có thể dẫn xuất.
Khi store bắt đầu to lên, tự hỏi: nó vẫn là một feature duy nhất không? Nếu câu trả lời là "khá là", tách nó ra ngay, trước khi feature tiếp theo biến nó thành một quả cầu state khiến bạn sợ chạm vào.
Bug form thường đến từ việc trộn ba thứ: những gì người dùng đang gõ, những gì server đã lưu, và những gì UI đang hiển thị.
Với quản lý state nhàm chán, coi form là client state cho đến khi submit. Dữ liệu server là phiên bản lưu cuối cùng. Form là draft. Đừng sửa object server trực tiếp. Sao chép giá trị vào draft state, cho phép người dùng thay đổi tự do, rồi submit và refetch (hoặc update cache) khi thành công.
Quyết sớm điều gì nên được giữ khi người dùng điều hướng đi. Một lựa chọn sẽ tránh nhiều bug bất ngờ. Ví dụ, chế độ inline edit và dropdown mở thường nên reset, trong khi bản nháp wizard dài hoặc bản nháp tin nhắn chưa gửi có thể được lưu. Persist qua reload chỉ khi người dùng mong đợi rõ ràng (như checkout).
Giữ quy tắc validate ở một nơi. Nếu bạn rải rule qua inputs, submit handler và helper, bạn sẽ có lỗi không khớp. Ưu tiên một schema (hoặc một hàm validate()), và để UI quyết khi nào hiển thị lỗi (on change, on blur, hoặc on submit).
Ví dụ: bạn tạo màn Edit Profile trong Koder.ai. Load profile đã lưu là server state. Tạo draft cục bộ cho các field form. Hiển thị "unsaved changes" bằng cách so sánh draft và saved. Nếu người dùng cancel, bỏ draft và hiển thị bản server. Nếu họ save, submit draft rồi thay thế saved bằng response từ server.
Khi app React được tạo tự động lớn lên, thường bạn sẽ có cùng dữ liệu ở ba nơi: component state, global store, và cache. Fix thường không phải thư viện mới mà là chọn một "nhà" cho mỗi mẩu dữ liệu.
Một luồng cleanup hiệu quả trong hầu hết app:
filteredUsers nếu bạn có thể tính từ users + filter. Ưu tiên selectedUserId hơn selectedUser duplicated.Ví dụ: một CRUD app do Koder.ai tạo thường bắt đầu với useEffect fetch cộng một bản sao trong global store. Sau khi bạn tập trung server state, danh sách tới từ một query, và "refresh" trở thành invalidation thay vì sync thủ công.
Về đặt tên, giữ nó nhất quán và nhàm chán:
users.list, users.detail(id)ui.isCreateModalOpen, filters.userSearchopenCreateModal(), setUserSearch(value)users.create, users.update, users.deleteMục tiêu là một nguồn chân lý cho mỗi thứ, với ranh giới rõ ràng giữa server state và client state.
Vấn đề state bắt đầu nhỏ, rồi một ngày bạn thay đổi một field và ba phần UI không đồng ý về giá trị "thật".
Dấu hiệu rõ nhất là dữ liệu bị nhân đôi: cùng user hoặc cart sống trong component, global store và request cache. Mỗi bản sao cập nhật ở thời điểm khác nhau, và bạn viết thêm code chỉ để giữ chúng bằng nhau.
Một dấu hiệu khác là mã đồng bộ: effects đẩy state qua lại. Các pattern như "khi query thay đổi thì update store" và "khi store thay đổi thì refetch" có thể chạy ổn cho đến khi một edge case khiến giá trị stale hoặc vòng lặp xảy ra.
Một vài dấu hiệu đỏ nhanh:
needsRefresh, didInit, isSaving mà không ai xóa.Ví dụ: bạn sinh dashboard bằng Koder.ai và thêm modal Edit Profile. Nếu profile lưu trong query cache, sao chép vào global store và nhân đôi trong local form state, bạn giờ có ba nguồn chân lý. Lần đầu bạn thêm refetch nền hoặc optimistic updates, sự không khớp sẽ lộ ra.
Khi thấy những dấu hiệu này, động tác nhàm chán là chọn một chủ sở hữu duy nhất cho mỗi mẩu dữ liệu và xóa các bản sao.
Lưu "phòng khi" là một trong những cách nhanh nhất làm state đau đầu, đặc biệt trong app được tạo tự động.
Sao chép response API vào global store là bẫy phổ biến. Nếu dữ liệu đến từ server (lists, details, profile), đừng sao chép mặc định vào client store. Chọn một nhà cho dữ liệu server (thường là query cache). Dùng client store cho các giá trị UI mà server không biết.
Lưu giá trị dẫn xuất là bẫy khác. Counts, danh sách lọc, tổng, canSubmit, và isEmpty thường nên được tính từ inputs. Nếu performance thực sự là vấn đề, memoize sau, nhưng đừng bắt đầu bằng cách lưu kết quả.
Một mega-store cho mọi thứ (auth, modals, toasts, filters, drafts, onboarding flags) sẽ trở thành bãi rác. Tách theo ranh giới feature. Nếu state chỉ dùng bởi một màn, giữ nó local.
Context tốt cho giá trị ổn định (theme, current user id, locale). Với giá trị thay đổi nhanh, nó có thể gây re-render rộng. Dùng Context cho wiring, và state component (hoặc store nhỏ) cho giá trị UI thay đổi thường xuyên.
Cuối cùng, tránh đặt tên không nhất quán. Query key và field store gần giống nhau tạo duplication tinh vi. Chọn một chuẩn đơn giản và theo nó.
Khi bạn muốn thêm "chỉ một biến state nữa", làm bài kiểm tra quyền sở hữu nhanh.
Đầu tiên, bạn có thể chỉ ra một chỗ nơi fetch và cache xảy ra (một công cụ query, một tập query key)? Nếu cùng dữ liệu được fetch ở nhiều component rồi còn được copy vào store, bạn đã trả lãi rồi.
Thứ hai, giá trị này chỉ cần trong một màn không (như "filter panel mở")? Nếu có, nó không nên global.
Thứ ba, bạn có thể lưu ID thay vì sao chép object? Lưu selectedUserId và đọc user từ cache hoặc list.
Thứ tư, nó có phải derived? Nếu có thể tính từ state hiện có, đừng lưu.
Cuối cùng, làm bài trace test một phút. Nếu đồng đội không thể trả lời "giá trị này đến từ đâu?" (prop, local state, server cache, URL, store) trong dưới một phút, sửa quyền sở hữu trước khi thêm state nữa.
Hình dung một admin app được tạo (ví dụ từ prompt trong Koder.ai) với ba màn: customer list, customer detail, và edit form.
State bình tĩnh khi có những nhà rõ ràng:
List và detail đọc server state từ query cache. Khi bạn save, bạn không lưu lại customers trong global store. Bạn gửi mutation, rồi để cache refresh hoặc update.
Với màn edit, giữ draft form local. Khởi tạo từ customer fetch, nhưng tách riêng khi người dùng bắt đầu gõ. Bằng vậy, view detail có thể refresh an toàn mà không ghi đè những thay đổi chưa hoàn thành.
Optimistic UI là nơi team thường nhân đôi mọi thứ. Thường bạn không cần.
Khi user nhấn Save, chỉ cập nhật record customer trong cache và mục tương ứng trong list, rồi rollback nếu request thất bại. Giữ draft trong form cho đến khi save thành công. Nếu thất bại, hiển thị lỗi và giữ draft để user thử lại.
Giả sử bạn thêm bulk edit và nó cũng cần selected rows. Trước khi tạo store mới, hỏi: state này có cần sống qua navigation và refresh không?
Các màn được sinh có thể nhân lên nhanh, và điều đó tốt cho tới khi mỗi màn mang quyết định state riêng. Ghi một ghi chú ngắn trong repo: gì được coi là server state, gì là client state, và công cụ nào sở hữu từng loại. Giữ nó ngắn để mọi người thực sự theo.
Thêm thói quen PR nhỏ: gắn nhãn mỗi state mới là server hay client. Nếu là server state, hỏi "nó load ở đâu, nó được cache thế nào, và cái gì invalidate nó?". Nếu là client state, hỏi "ai sở hữu nó, và khi nào nó reset?".
Nếu bạn dùng Koder.ai (koder.ai), Planning Mode có thể giúp đồng ý ranh giới state trước khi sinh màn mới. Snapshot và rollback cho bạn cách an toàn để thử nghiệm khi thay đổi state đi lệch hướng.
Chọn một feature (như edit profile), áp dụng quy tắc từ đầu tới cuối, và để đó làm ví dụ mọi người sao chép.
Bắt đầu bằng cách gắn nhãn cho từng mẩu state là server, client (UI), hoặc derived.
isValid).Khi đã gắn nhãn, đảm bảo mỗi mục có một nơi sở hữu rõ ràng (query cache, state cục bộ của component, URL, hoặc một store nhỏ).
Dùng bài kiểm tra nhanh này: “Tôi có thể refresh trang và xây lại cái này từ server không?”
Ví dụ: danh sách project là server state; ID hàng được chọn là client state.
Vì nó tạo ra hai nguồn chân lý.
Nếu bạn fetch users rồi sao chép vào useState hoặc store toàn cục, bạn phải giữ chúng đồng bộ khi xảy ra:
Quy tắc mặc định: và chỉ tạo state cục bộ cho các quan tâm UI hoặc draft.
Chỉ lưu derived khi bạn thực sự không thể tính nó một cách rẻ.
Thông thường nên tính từ các đầu vào có sẵn:
visibleUsers = users.filter(...)total = items.reduce(...)canSubmit = isValid && !isSavingNếu hiệu năng trở thành vấn đề có đo lường, hãy ưu tiên hoặc cấu trúc dữ liệu tốt hơn trước khi thêm state có thể bị stale.
Mặc định: dùng một công cụ cho server-state (thường là TanStack Query) để component chỉ cần “yêu cầu dữ liệu” và hiển thị loading/error.
Các nguyên tắc thực tế:
Giữ nó cục bộ cho đến khi bạn có thể nêu được nhu cầu chia sẻ cụ thể.
Quy tắc nâng trạng thái lên:
Cách này tránh biến store toàn cục thành nơi chứa linh tinh cho mọi flag UI.
Lưu IDs và flags nhỏ, không lưu nguyên object server.
Ví dụ:
selectedUserIdselectedUser (sao chép object)Khi render, lookup user từ list/cache. Điều này giúp refetch nền và cập nhật hoạt động đúng mà không cần sync thêm.
Xử lý form như một draft (client state) cho tới khi submit.
Mẫu thực tế:
Điều này tránh chỉnh sửa trực tiếp object server và xung đột với refetch.
Các dấu hiệu đỏ:
needsRefresh, didInit, isSaving cứ tích tụ.Các màn hình generated dễ rơi vào mô thức khác nhau. Một biện pháp đơn giản: chuẩn hóa quyền sở hữu:
Nếu bạn dùng Koder.ai, dùng Planning Mode để quyết quyền sở hữu trước khi sinh màn hình mới, và tận dụng snapshot/rollback khi thử nghiệm thay đổi state.
useMemoTránh rải refetch() khắp nơi “cho chắc”.
Cách sửa thường không phải thư viện mới mà là xóa các bản sao và chọn một chủ sở hữu duy nhất cho mỗi giá trị.