Part 07 · State Design · 7.1.05

State modeling, forms và context

Đặt state tại owner thấp nhất cần điều phối nó, mô hình hóa transition thay vì chồng boolean, và chọn đúng nơi lưu local UI, URL, server data hay shared workflow state.

Architecture rule: “global” không phải mặc định của dữ liệu dùng ở nhiều nơi. Trước tiên xác định source of truth, lifetime, owner và cách reset; chỉ lift hoặc externalize khi consumer thực sự cần phối hợp.

1. State ownership và nguồn sự thật

Mỗi state nên có một owner rõ ràng. Colocate state với component gần nhất dùng nó; khi hai sibling phải luôn nhất quán, lift lên common parent rồi truyền value và event callback xuống. Tránh copy cùng entity vào local state, context và cache vì các bản sao sẽ lệch nhau.

Loại stateNơi phù hợpVí dụ
Local UIComponent/subtree gần nhấtPopover mở, draft tạm, selected tab
Shareable navigationURL/search paramsFilter, page, sort, selected resource
Server stateFramework loader hoặc query cacheUsers, orders, freshness, retry
Shared workflowCommon owner, reducer hoặc store có scopeMulti-step checkout, editor session
Cross-cutting stable valueContextTheme, locale, authenticated identity

Nếu value có thể derive từ props/state hiện tại, tính nó trong render hoặc selector. Nếu reset phụ thuộc identity của entity, dùng key hoặc explicit action; đừng tạo Effect chỉ để giữ hai state đồng bộ.

2. Controlled và uncontrolled forms

Controlled input lấy value/checked từ state và cập nhật synchronously trong onChange. Nó phù hợp khi UI khác phụ thuộc từng keystroke, cần format/validation trực tiếp hoặc nhiều field phối hợp. Uncontrolled input để DOM giữ giá trị ban đầu qua defaultValue/defaultChecked, rồi đọc bằng FormData hoặc ref khi submit.

function ProfileForm() {
  const [name, setName] = useState('');

  function submit(event) {
    event.preventDefault();
    saveProfile({ name: name.trim() });
  }

  return <form onSubmit={submit}>
    <label>Tên
      <input value={name} onChange={e => setName(e.target.value)} />
    </label>
    <button>Lưu</button>
  </form>;
}
Invariant: một input không được chuyển giữa controlled và uncontrolled trong lifetime. Khởi tạo text bằng '', checkbox bằng boolean; không để value bất ngờ thành null/undefined.

3. Validation và submission là UX state

Phân biệt field value, touched/dirty, client error, pending, server error và success. Không hiển thị lỗi quá sớm nếu làm gián đoạn nhập liệu; nhưng lỗi khi submit phải liên kết với field, có summary phù hợp và giữ input của user. Disable toàn form trong pending chỉ khi business rule đòi hỏi; luôn chống duplicate submit ở boundary xử lý.

4. Reducer cho transition phức tạp

useReducer phù hợp khi nhiều event tác động lên state liên quan hoặc cần tập trung invariants. Action nên mô tả điều đã xảy ra — itemAdded, submitStarted — thay vì lộ setter implementation. Reducer phải pure: không request, timer, mutation hoặc sinh ID ngẫu nhiên bên trong.

function reducer(state, action) {
  switch (action.type) {
    case 'submitted':
      if (!state.canSubmit) return state;
      return { ...state, status: 'pending', error: null };
    case 'failed':
      return { ...state, status: 'error', error: action.error };
    default:
      throw new Error(`Unknown action: ${action.type}`);
  }
}

Reducer không tự loại bỏ impossible states nếu model vẫn là nhiều boolean độc lập. Dùng status/discriminated union hoặc state machine để một transition tạo ra state hợp lệ. Unit test transition table, unknown action policy và invariant; integration test effect thật nằm ngoài reducer.

5. Context: transport, không phải state architecture

Context truyền value xuyên qua tree mà không prop-drill từng tầng. Khi provider nhận value mới theo so sánh Object.is, consumer đọc context đó re-render; memo không chặn consumer nhận context mới. Vì vậy, object/function tạo mới vô ý ở provider có thể mở rộng phạm vi cập nhật.

Split context theo change frequency và responsibility, ví dụ state context riêng dispatch context. Memoize provider value chỉ khi inputs ổn định và profiler chứng minh cần thiết. Context phù hợp cho dependency có scope; nó không tự cung cấp selector, normalized cache, persistence, devtools hay concurrency policy như một state library chuyên dụng.

const TodosContext = createContext(null);
const TodosDispatchContext = createContext(null);

function TodosProvider({ children }) {
  const [todos, dispatch] = useReducer(todosReducer, []);
  return <TodosContext value={todos}>
    <TodosDispatchContext value={dispatch}>{children}</TodosDispatchContext>
  </TodosContext>;
}

6. Decision và kiểm thử

Bắt đầu bằng local state, lift khi có coordination, encode navigation vào URL, giữ remote data trong server cache, rồi mới chọn context/store cho shared client workflow. Kiểm thử reducer độc lập, form bằng hành vi user, URL bằng back/forward/reload, và context bằng consumer thực thay vì assert implementation.

Review checklist: một source of truth; không duplicate server cache; form không đổi controlled mode; pending/error có UX contract; reducer pure và action mang domain meaning; context value có scope và update frequency hợp lý.
Tài liệu: Sharing State Between Components · Extracting State Logic into a Reducer · Scaling Up with Reducer and Context · Input API · useContext API · useActionState API