Khi biểu mẫu, bản xem trước và nút Lưu cần dùng chung một bản nháp
Khi biểu mẫu, bản xem trước và nút Lưu cùng làm việc trên một tin tuyển dụng, chúng cần thống nhất về một bản nháp. Mình tạo ra createContextState để bản nháp ấy có chỗ ở rõ ràng trong React.
Tóm tắt
Khi bạn sửa một tin tuyển dụng, biểu mẫu, bản xem trước và nút Lưu dùng chung một bản nháp, nên bản nháp nằm ở đâu sẽ quyết định chúng có khớp nhau hay không. Mình chia sẻ cách chọn chỗ cho bản nháp ấy, một câu hỏi cứ quay lại khi mình làm Navigos Talent One, sản phẩm tuyển dụng do cả nhóm cùng làm. Mình tạo ra createContextState cho việc này và công bố thành gói React mã nguồn mở kèm ví dụ.

Thử hình dung bạn đang sửa một tin tuyển dụng. Ô tiêu đề nằm ở một bên trang, bản xem trước của tin nằm ở bên kia, còn nút Lưu (Save) ở phía trên cả hai. Vì cả ba cùng làm việc trên một bản nháp, khi đổi tiêu đề bạn mong bản xem trước đổi theo, và khi bấm Lưu bạn mong nút ấy gửi đi đúng phiên bản đang hiện trước mắt. Với bạn, tất cả chỉ là một việc, nhưng bên trong trang, ba thứ ấy là những thành phần (component) riêng biệt, và chỗ đặt bản nháp chung sẽ quyết định hai chuyện: các thành phần có luôn khớp nhau hay không, và một chỉnh sửa nhỏ ở một góc có khiến cả trang phải xử lý lại những phần chẳng cần xử lý hay không.
Câu hỏi đó cứ quay lại nhiều lần khi mình làm Navigos Talent One, một sản phẩm tuyển dụng do cả nhóm cùng làm, với các phân hệ như đăng tin, tìm ứng viên, công cụ chiến dịch và chỉnh sửa trang đích (landing page). Vì sản phẩm có nhiều quy trình chỉnh sửa dài cùng giao diện đa ngôn ngữ, phía frontend thường xuyên phải trả lời xem phần nào của giao diện chịu trách nhiệm giữ trạng thái mà nhiều thành phần cùng dùng.
Cách mình chọn là xem bản nháp như một cuốn sổ mở sẵn trên bàn cho một việc cụ thể. Ai đang làm việc đó cũng đọc và ghi vào cùng những trang sổ, và không ai phải chuyền giấy cho nhau để biết bản mới nhất. Mình tạo ra createContextState để mỗi trang hoặc mỗi tính năng có một cuốn sổ như vậy: một nơi lưu trạng thái dùng chung, gọi là store, của riêng trang hoặc tính năng đó, kèm cách để từng thành phần chỉ đọc đúng phần mình cần. Mẫu này được công bố trong gói @khanglvm/react, và kho mã nguồn có cả phần triển khai lẫn hướng dẫn.
Áp vào trình sửa tin tuyển dụng, mỗi phần của mẫu này giải quyết một nhu cầu trong lúc sửa tin. Provider đánh dấu phần nào của trang sở hữu bản nháp, selector (hàm chọn dữ liệu) giúp mỗi thành phần chỉ theo dõi giá trị mình hiển thị, hàm lấy snapshot cho một thao tác đọc bản nháp mới nhất ngay lúc thao tác chạy, còn hàm cập nhật có thể trả lại một cách hoàn tác khi yêu cầu gửi lên máy chủ thất bại.
Khi tiêu đề và bản xem trước phải khớp nhau
Trước khi dùng đến store dùng chung, mình hỏi giá trị này thuộc về ai. Ô nhập chỉ một thành phần dùng thì cứ để trong thành phần đó, dữ liệu lấy từ máy chủ có thể nằm trong bộ nhớ đệm như TanStack Query, còn trạng thái cần có trong đường link để chia sẻ thì nên nằm trên URL. Bản nháp tin tuyển dụng rơi vào khoảng giữa, vì nhiều phần trên cùng một trang dùng đến bản nháp trong khi bên ngoài trang không cần nó. Vậy nên một Provider bọc quanh phần giao diện sở hữu bản nháp, và biểu mẫu, bản xem trước lẫn nút Lưu đều lấy dữ liệu từ cùng một store mà không phải chuyền tiêu đề qua những thành phần bố cục chẳng liên quan gì.

Khi bạn rời trình sửa sang trang khác
Chỗ đặt Provider cũng quyết định thứ gì còn giữ lại khi người dùng chuyển sang nơi khác. Ngôn ngữ giao diện và công ty đang chọn có thể cần giữ nguyên qua các lần chuyển trang, nên chúng nằm ở cấp cao hơn từng trang, còn một ngăn trượt chỉ dùng trong một trình sửa thì có thể ở cùng trình sửa đó và biến mất cùng trình sửa. Đặt Provider đúng ranh giới giúp mình chủ động quyết định mỗi phần trạng thái tồn tại đến khi nào.

Khi một thứ khác trên trang thay đổi
Dùng chung một store sẽ tốn kém nếu mỗi thay đổi lại đánh thức mọi thành phần, nên mỗi phần chỉ theo dõi đúng thứ mình hiển thị. Bộ đếm số hàng đang chọn chỉ cần con số ấy và chẳng có lý do gì phải vẽ lại khi ai đó mở một ngăn trượt không liên quan, cũng như ô báo bước trong trình sửa tin tuyển dụng chỉ cần biết bạn đang ở bước nào. Selector cho phép một thành phần đăng ký theo dõi đúng giá trị nó hiển thị, nên thành phần không phải vẽ lại chỉ vì phần dữ liệu khác trong cùng store thay đổi.
Khi nút Lưu phải chờ kiểm tra dữ liệu
Nút Lưu thì cần một kiểu truy cập khác, vì sau khi bạn bấm, hàm xử lý bất đồng bộ có thể phải chờ kiểm tra tính hợp lệ của dữ liệu trong lúc bạn vẫn sửa tiếp, và giá trị lấy ngay lúc bấm có thể đã cũ khi việc kiểm tra xong. Đọc một bản chụp trạng thái (snapshot) mới sau khi chờ giúp nút Lưu lưu đúng bản nháp mới nhất, cũng chính là bản bạn mong nút ấy lưu ngay từ đầu.
Khi yêu cầu gửi lên máy chủ thất bại
Một số thao tác cập nhật màn hình trước khi máy chủ xác nhận, nên nếu yêu cầu thất bại, giao diện cần một đường quay về những gì người dùng đã thấy trước đó. Hàm cập nhật có thể trả về một hàm hoàn tác cho trường hợp này, còn với những chỉnh sửa diễn ra liên tục và không cần hoàn tác thì có thể tắt phần xử lý thêm ấy đi.
Chính những lúc như vậy đã định hình mẫu này: một nơi quản lý trạng thái, cách để mỗi thành phần chỉ theo dõi phần dữ liệu cần dùng và cách đọc bản nháp mới nhất khi một thao tác cần đến. Phần ghi chú kỹ thuật bên dưới minh họa cách mình dùng mẫu này bằng code ví dụ, không đưa code nội bộ của sản phẩm vào bài.
Ví dụ React và ghi chú triển khai
Hàm tạo kết hợp React Context, useSyncExternalStore và Immer: Context tìm store gần nhất, các thành phần đăng ký theo dõi những snapshot đã chọn qua useSyncExternalStore, còn Immer lo phần cập nhật bản nháp lồng nhau cùng các bản vá nghịch đảo dùng để hoàn tác khi cần. Theo tài liệu, gói hỗ trợ React 18 và 19, được phân phối dưới dạng ES modules cùng khai báo kiểu TypeScript.
npm install @khanglvm/react
Tạo bản nháp thuộc phạm vi trang
Ví dụ này giữ bản nháp tin tuyển dụng dưới một Provider, giống trình sửa đã kể ở trên. Các lời gọi api đại diện cho cơ chế lưu của chính ứng dụng.
import { createContextState } from "@khanglvm/react"
type JobDraftState = {
step: "details" | "requirements" | "preview"
draft: {
title: string
skills: string[]
}
saving: boolean
error?: string
}
export const {
Provider: JobDraftStateProvider,
useContextStateValue: useJobDraftValue,
useSetContextState: useSetJobDraftState,
useStateSnapshotGetter: useJobDraftSnapshot,
} = createContextState<JobDraftState>("JobDraft")
export function JobDraftPage({ children }: { children: React.ReactNode }) {
return (
<JobDraftStateProvider
initialState={{
step: "details",
draft: { title: "", skills: [] },
saving: false,
}}
>
{children}
</JobDraftStateProvider>
)
}
export function StepIndicator() {
const step = useJobDraftValue((state) => state.step)
return <span>{step}</span>
}
export function AddSkillButton({ skill }: { skill: string }) {
const setState = useSetJobDraftState(false)
return (
<button
onClick={() => {
setState((draft) => {
draft.draft.skills.push(skill)
})
}}
>
Add skill
</button>
)
}
export function SaveDraftButton() {
const setState = useSetJobDraftState()
const getSnapshot = useJobDraftSnapshot()
async function saveDraft() {
const revert = setState((draft) => {
draft.saving = true
draft.error = undefined
})
try {
await api.saveDraft(getSnapshot((state) => state.draft))
setState((draft) => {
draft.saving = false
})
} catch {
revert()
setState((draft) => {
draft.error = "Could not save the draft."
})
}
}
return <button onClick={saveDraft}>Save</button>
}
Mình đổi tên các hook trả về theo trạng thái mà chúng phụ trách, nhờ vậy nơi sử dụng sẽ import hook của bản nháp tin tuyển dụng thay vì một hook trạng thái chung chung.
Chọn kiểu truy cập mà thành phần cần
| API | Mục đích |
|---|---|
Provider | Sở hữu store cho một cây con. |
useContextState(selector) | Đọc trạng thái được chọn, đồng thời truy cập hàm cập nhật và hàm lấy snapshot. |
useContextStateValue(selector) | Đăng ký theo dõi giá trị mà thành phần hiển thị. |
useSetContextState(enableRevert?) | Cập nhật trạng thái mà không đăng ký theo dõi. |
useStateSnapshotGetter() | Đọc trạng thái mới nhất trong hàm xử lý sự kiện hoặc bất đồng bộ. |
withContextProvider(component, config) | Gắn Provider và ánh xạ props vào trạng thái ban đầu. |
useSetPostFlush() | Chạy phần xử lý dữ liệu dẫn xuất tốn kém sau một đợt thay đổi. |
Trên trang tìm kiếm, thanh bộ lọc có thể đọc bộ lọc trong khi thanh công cụ đọc dữ liệu đang được chọn. Khi ai đó bấm xuất dữ liệu, thao tác này có thể lấy snapshot của bộ lọc hiện tại. Giá trị ngôn ngữ thì nằm cao hơn, ở phần trạng thái chung của khung ứng dụng phía trên từng trang, nên nhãn có thể đổi ngôn ngữ mà không làm mất phần biểu mẫu đang điền hay đóng các ngăn trượt đang mở.
Selector chỉ nên suy ra kết quả từ trạng thái, vì thời gian, giá trị ngẫu nhiên hoặc lời gọi mạng có thể thay đổi độc lập với trạng thái được chọn và khiến việc theo dõi không còn đáng tin cậy. Phần triển khai lưu đệm các kết quả selector bằng nhau về cấu trúc để giữ ổn định tham chiếu của chúng.
Đọc sau khi chờ bất đồng bộ
Hàm lấy snapshot hữu ích khi quá trình kiểm tra tính hợp lệ hoàn tất sau lúc người dùng đã sửa thêm:
function SaveButton() {
const getEditorSnapshot = useEditorSnapshot()
async function save() {
await api.validate()
const latestDraft = getEditorSnapshot((state) => state.draft)
await api.save(latestDraft)
}
return <button onClick={save}>Save</button>
}
Lần đọc đó không đăng ký theo dõi những thay đổi tiếp theo, nên giá trị mà thành phần hiển thị vẫn cần hook selector. Hàm xử lý sự kiện, phím tắt và thao tác lưu có cơ chế chờ người dùng ngừng sửa một lúc (debounce) có thể dùng hàm lấy snapshot khi cần giá trị hiện tại đúng lúc hành động.
Hoàn tác cập nhật hiển thị trước khi máy chủ xác nhận
Hàm cập nhật có thể giữ các bản vá nghịch đảo cần để hoàn tác sau khi yêu cầu thất bại:
async function archiveCandidate(id: string) {
const revert = setCandidatesState((draft) => {
const candidate = draft.itemsById[id]
candidate.archived = true
})
try {
await api.archiveCandidate(id)
} catch {
revert()
}
}
Với những chỉnh sửa thường xuyên trong trình dựng trang, useSetBuilderState(false) vẫn cập nhật bản nháp nhưng bỏ qua việc tạo bản vá hoàn tác. Nhiều thay đổi cũng có thể được đẩy cập nhật cùng nhau qua một microtask được xếp hàng, và hook chạy sau đợt cập nhật hữu ích khi trình chỉnh sửa cần tính lại dữ liệu dẫn xuất tốn kém một lần sau cả đợt.
Làm rõ thời gian tồn tại và việc kết xuất phía máy chủ
Mỗi Provider tạo store riêng, nên dùng chung hàm tạo không làm các Provider hay các yêu cầu đến máy chủ dùng chung trạng thái. Giá trị ban đầu ở máy chủ và phía trình duyệt vẫn cần khớp nhau khi React tiếp quản giao diện đã kết xuất từ máy chủ (hydration).
Trạng thái ở khung ứng dụng có thể tồn tại qua các lần chuyển trang thông thường ở phía trình duyệt, trong khi trạng thái tính năng chỉ tồn tại khi Provider sở hữu nó vẫn được gắn vào cây giao diện. Trạng thái gắn với URL có thể dùng một store tham số truy vấn riêng với cùng cách đăng ký theo dõi; đưa trạng thái vào store này không tự khiến nó gắn với URL.
Gói còn hỗ trợ bản dịch có kiểm tra kiểu, sự kiện trình duyệt và các hàm tiện ích cho props cùng children, còn hướng dẫn trạng thái trình bày API và các giới hạn. Redux, Zustand và Jotai vẫn là những lựa chọn khác, với công cụ và mô hình trạng thái riêng; quy trình đã có của nhóm có thể là lý do hợp lý để tiếp tục dùng chúng.
Mình dùng mẫu này khi nhiều thành phần cùng làm việc trên một bản nháp và nơi sở hữu bản nháp đi theo cây trang hoặc tính năng, còn thành phần nhỏ thì vẫn cứ giữ trạng thái riêng của mình. Điều mình muốn là mọi phần trên trang đều thống nhất về công việc người dùng đang làm, trong một ranh giới sở hữu mà cả nhóm đều nắm được, nên nếu bạn định làm điều tương tự, hãy bắt đầu bằng câu hỏi bản nháp thuộc về ai rồi mới quyết định lưu nó ở đâu.
