Mô hình State Frontend: Đặt từng loại state đúng chỗ
Suy luận về frontend state theo ownership, lifetime, scope và persistence để local UI, URL state, shared client state, remote data và derived values không trở thành các nguồn sự thật cạnh tranh nhau.
Bản đồ học tập phát triển phần mềm bởi Tran Trong Thuc · Về dự án Atlas · Cập nhật lần cuối: 22 thg 9, 2026
Các mô hình Frontend State: Sở hữu, dẫn xuất hoặc lưu bền
Tóm tắt
Một người dùng lọc danh mục phòng khách sạn theo tiêu chí "Giá < 1 triệu" và "Gần biển", tìm thấy phòng ưng ý và sao chép link trình duyệt gửi cho bạn. Khi người bạn mở link, trang web lại hiển thị danh sách phòng ngẫu nhiên không có bộ lọc. Cùng lúc đó tại tab gốc, một lập trình viên sao chép dữ liệu API từ server vào cả store toàn cục lẫn local state component, gây ra vòng lặp useEffect đồng bộ vô tận làm đóng băng trình duyệt. Nguyên nhân gốc rễ? Đặt nhầm trạng thái URL vào local state và tạo ra các bản sao có thể ghi cạnh tranh nhau từ dữ liệu thuộc sở hữu của máy chủ.
💡 Quy tắc bỏ túi: Mỗi sự thật trạng thái chỉ được có một chủ sở hữu duy nhất—tất cả những thứ còn lại hãy tính toán dẫn xuất (derive). Nếu trạng thái mang ý nghĩa điều hướng (bộ lọc, tab, từ khóa), hãy lưu vào URL để người dùng có thể chia sẻ và bookmark; nếu là dữ liệu từ API, hãy coi đó là cache tạm thời thuộc sở hữu của máy chủ; không bao giờ sao chép dữ liệu từ server hay props vào local state rồi dùng
useEffectđể đồng bộ thủ công.
- Phân loại trạng thái theo 4 chiều: Luôn phân loại thông tin theo: chủ sở hữu có thẩm quyền (local UI, URL, server cache, workflow dùng chung), thời gian sống, phạm vi ảnh hưởng và nhu cầu lưu bền.
- URL là chủ sở hữu trạng thái: Đưa các tham số tìm kiếm, view đang kích hoạt, bộ lọc và phân trang vào URL search params để lịch sử trình duyệt (back/forward) và chia sẻ link hoạt động hoàn hảo.
- Một nguồn sự thật duy nhất & trạng thái dẫn xuất: Tính toán giá trị trực tiếp trong quá trình render (ví dụ:
const visibleItems = items.filter(...)) thay vì lưu bản sao phụ đòi hỏi phải cập nhật đồng bộ thủ công. - Dữ liệu server thuộc sở hữu của server: Coi dữ liệu API là bản cache bất đồng bộ của máy chủ; dùng công cụ quản lý cache chuyên dụng (TanStack Query, SWR, RSC) thay vì nạp dữ liệu thô vào global store client.
- Cạm bẫy chết người: Sao chép dữ liệu server hoặc props vào local
useStaterồi cố gắng duy trì đồng bộ hai chiều bằnguseEffect—chắc chắn sẽ gây ra giao diện lỗi thời, race condition và các vòng lặp re-render vô tận.
State là mô hình của fact thay đổi, không phải storage API
Một failure mode phổ biến là chọn tool trước khi định nghĩa state. “Team dùng Zustand”, “cho vào Context” hay “lưu localStorage” là implementation decision, chưa phải state model.
Model phải có trước:
- fact nào đang thay đổi;
- event nào làm nó thay đổi;
- ai là authoritative owner;
- consumer nào cần nó;
- nó còn ý nghĩa trong bao lâu;
- nguồn khác có thể tái tạo nó không.
Nếu hai nơi đều tin rằng mình có quyền ghi cùng một fact, synchronization đã trở thành một phần kiến trúc dù team có chủ đích hay không.
Một taxonomy state thực dụng
Local ephemeral UI state
Loại state này tồn tại để phục vụ interaction gần đó và thường biến mất cùng component hoặc route:
- popover có đang mở không;
- row nào đang focus;
- drag position tạm thời;
- disclosure panel có expand không;
- unsaved local draft khi chỉ một editor sở hữu nó.
Giữ local nếu không có owner nào khác cần phối hợp. Đẩy mọi boolean vào global store chỉ làm coupling tăng mà không thêm semantics hữu ích.
Form và draft state
Form input cần được nhìn riêng vì lifetime có thể dài hơn một control nhưng ngắn hơn persistent domain data.
Form có thể sở hữu editable draft trong khi backend sở hữu entity đã save. Hai thứ này là state khác nhau. Draft có thể dirty, invalid hoặc chưa hoàn chỉnh; persisted server entity không nên được xem như đã thay đổi sau từng keystroke.
URL và navigation state
Filter, search term, sort order, pagination, selected tab hay view mode thường mang navigation semantics. Nếu người dùng cần bookmark, chia sẻ, reload hoặc dùng Back/Forward mà vẫn khôi phục cùng một view, URL là candidate rất mạnh cho ownership.
Không phải mọi UI toggle đều nên nằm trong URL. Hover state, tooltip tạm thời hay animation phase thường không cần addressability. Hãy kiểm tra semantics: một người khác hoặc một history entry sau này có cần state chính xác này để tái tạo view không?
Shared client state
Một số fact do client sở hữu thật sự cần đi qua nhiều component xa nhau: workflow nhiều bước đang dang dở, editor session chỉ tồn tại phía client, hoặc UI preference được nhiều nhánh trong tree sử dụng.
Bắt đầu từ owner hẹp nhất có đủ context để phối hợp tất cả consumer. Nâng state lên closest common parent khi sibling cần một nguồn sự thật chung. Dùng Context hoặc global store khi ownership thật sự phủ một subtree rộng hay cả application—không chỉ để tránh truyền vài props.
Global store là infrastructure hữu ích, nhưng global state có chi phí:
- nhiều component hơn có thể phụ thuộc và mutate cùng state;
- rule reset và lifecycle khó thấy hơn;
- test cần setup rộng hơn;
- persistence có thể vô tình giữ value stale hoặc gắn với user cũ;
- feature không liên quan có thể bị coupling qua selector/action chung.
Remote hoặc server-owned state
Backend entity, permission, inventory, account balance và server-generated search result không tự nhiên trở thành “client-owned” chỉ vì browser cache đang giữ một bản copy.
Server vẫn authoritative. Frontend cache sở hữu một quan sát cục bộ của remote state, cộng metadata như freshness hay request status. Cache invalidation, refetch, optimistic update và request deduplication sẽ được đào sâu chủ yếu ở bài kế Frontend Data Fetching.
Điểm này rất quan trọng: cho một object User đã fetch vào global store không biến store đó thành nguồn sự thật của user record.
Persistent browser state
localStorage, IndexedDB, cookie và cơ chế tương tự trả lời câu hỏi persistence, không trả lời câu hỏi ownership.
Chỉ persist value có semantics khôi phục rõ ràng. Theme preference có thể sống qua nhiều session. Một account balance do server sở hữu không thể trở thành authoritative chỉ vì browser vẫn giữ bản cũ trong storage.
Khi restore, hãy hỏi persisted state có cần version, expiry, validation, user scope hoặc reconciliation với server data mới hơn không.
Derived state thường không nên được lưu
Nếu một value có thể tính từ props/state hiện tại trong render, lưu thêm một writable copy sẽ tạo ra nghĩa vụ synchronization.
Giả sử product list sở hữu products và URL sở hữu sort=price. Sorted list là derived value. Nếu lưu thêm sortedProducts như state độc lập, mọi thay đổi products và sort đều phải cập nhật hai bản đúng cách.
Memoization có thể cache computation, nhưng cache không trở thành source of truth mới. Input vẫn định nghĩa value.
Đồng bộ hai writable copy là một design smell
Pattern kiểu “A đổi thì chạy effect để copy A sang B” nên được soi kỹ. Effect hợp lệ khi đồng bộ React với external system. Nhưng effect thường không cần thiết khi hai React value có thể cùng dùng một owner hoặc một value có thể derive từ value kia.
Các warning sign phổ biến:
- URL filter được copy vào component state rồi lại sync ngược về URL;
- server response được copy vào global store rồi update riêng ở đó;
- props được copy vào local state mỗi khi đổi;
fullNameđược lưu và update mỗi lầnfirstNamehaylastNameđổi;- cùng một selection được lưu vừa dưới dạng object vừa dưới dạng ID.
Trước khi thêm synchronization effect, hãy hỏi liệu có thể xóa một trong hai bản copy không.
Colocate trước; chỉ lift khi coordination yêu cầu
“Giữ state local” không có nghĩa “không bao giờ share state”. Nó có nghĩa đặt mutable state gần owner nhỏ nhất nhưng vẫn có đủ context để update đúng.
Nếu hai sibling cần phối hợp, nâng state lên closest common parent. Nếu cả một feature subtree cần nó, reducer + Context có thể hợp lý. Nếu nhiều feature xa nhau thật sự cần cùng một client-owned workflow state, store có thể phù hợp.
Hướng di chuyển state nên do ownership quyết định, không phải nỗi sợ prop drilling.
Scope và lifetime là hai trục độc lập
Một value có thể scope rộng nhưng lifetime ngắn, hoặc scope hẹp nhưng persistence dài.
Ví dụ:
- command palette cho cả route có scope rộng nhưng biến mất khi navigation;
- theme preference rất nhỏ nhưng ảnh hưởng toàn app và có thể tồn tại nhiều tháng;
- modal vừa local vừa ephemeral;
- upload draft chỉ thuộc một feature nhưng có thể sống qua refresh nhờ persistent storage.
Hãy ghi rõ các dimension này khi review thiết kế. Từ “global” một mình không giải thích được value tồn tại bao lâu hay ai có quyền sửa nó.
Tình huống production
Một catalog page lưu filter, sort và page ở bốn nơi: component state, global store, URL search params và localStorage. Mount effects copy value qua lại giữa các nơi. Người dùng đổi filter, mở product, bấm Back rồi chia sẻ URL cho đồng đội.
Hậu quả: Back/Forward khôi phục filter khác với URL đang hiển thị, shared link mở với default hoặc local preference cũ, reload phụ thuộc thứ tự effect, và engineer không xác định được layer nào authoritative khi có sự cố.
Nguyên nhân cốt lõi: team tối ưu cho convenience và persistence trước khi định nghĩa ownership. Bốn writable copy của navigation state trở thành các nguồn sự thật cạnh tranh, còn effect trở thành lớp keo synchronization mong manh.
Cách khắc phục chuẩn: dùng URL làm canonical owner cho shareable navigation state như filter/sort/page; giữ interaction thật sự ephemeral ở local; để remote-data cache biểu diễn server-owned result được derive từ URL query; chỉ persist preference có restore semantics rõ ràng; và tính derived values thay vì copy chúng.
Cách review vị trí state
Với mỗi state variable được đề xuất, hãy hỏi:
- Fact chính xác nào đang được biểu diễn?
- Ai là authoritative owner có quyền thay đổi nó?
- Lifetime của nó là gì?
- Scope rộng đến đâu?
- Nó có cần sống qua refresh hoặc session khác không?
- Back/Forward, bookmark hay share có cần tái tạo nó không?
- Có thể derive nó từ nguồn khác không?
- Team có đang tạo writable copy thứ hai phải sync không?
- Nếu là remote data, team có đang nhầm cache với authority của server không?
- Nếu là global state, consumer nào thật sự cần global ownership?
Tự kiểm tra
Một search page có ?query=react&page=3 trong URL. Khi mount, component copy hai value vào local state, update URL mỗi khi local state đổi, rồi dùng effect khác để reset local state khi browser navigation đổi URL. Hai chiều synchronization này có nên là default không?
Xem giải thích chi tiết
Thường là không. Nếu query và page có navigation semantics, URL có thể là single source of truth. Component đọc từ URL rồi derive request/view từ đó. Writable local copy riêng tạo thêm timing và synchronization case vốn không tồn tại khi chỉ một owner authoritative. Temporary input draft vẫn có thể local nếu semantics của nó khác committed navigation state.
Checklist mô hình state
- Chỉ định một canonical owner cho mỗi mutable fact.
- Ghi lifetime, scope và persistence như ba dimension riêng.
- Giữ ephemeral interaction state gần component hoặc feature sở hữu nó.
- Chỉ lift state khi nhiều consumer thật sự cần coordinated updates.
- Dùng URL state cho navigation state cần share/bookmark/Back/Forward.
- Derive value tính được thay vì lưu writable state trùng lặp.
- Xem synchronization effect giữa hai bản copy là tín hiệu cần review ownership.
- Dùng Context/global store cho client-owned state thật sự có phạm vi rộng, không như container mặc định.
- Xem remote cache là quan sát của server-owned state, không phải server authority.
- Chỉ persist browser state khi có restore, versioning, expiry và user-scope rule rõ ràng nếu cần.
Agent rule
Khi được hỏi frontend state nên sống ở đâu, đừng chọn library trước. Hãy xác định state owner, lifetime, scope, persistence và navigation semantics; loại bỏ derived copy dư thừa; rồi mới chọn cơ chế nhỏ nhất giữ được một source of truth rõ ràng.
Nguồn tham khảo
Các nguồn chính được kiểm tra ngày 2026-09-16:
Server và Client Components: Chọn ranh giới thực thi
Suy luận về React Server và Client Components như ranh giới thực thi và module, cách dữ liệu/JSX đi qua ranh giới, và cách vị trí ranh giới ảnh hưởng JavaScript phía client cùng chi phí hydration.
Frontend Data Fetching: Làm dữ liệu từ xa trở nên tường minh
Suy luận về frontend data fetching như một hệ thống gồm request identity, vị trí fetch, freshness, caching, race, mutation invalidation và các trạng thái async mà người dùng nhìn thấy.