jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

React 19 · Phần 01 — UI là value, không phải template

Xây mental model đầu tiên: component tạo React element, React giữ UI đồng bộ với state, còn DOM chỉ là output được commit vào browser.

Một extension, analytics script hoặc đoạn code legacy sửa thẳng nội dung trong DOM. Màn hình trông đúng trong vài giây, nhưng khi người dùng chọn issue khác, đoạn text đó biến mất. Nếu chỉ nghĩ React là “template có state”, hành vi này trông như React tự ý phá công việc của bạn.

Thực ra có hai writer đang tranh quyền sở hữu cùng một node. React không giữ template; nó tính một description của UI từ input hiện tại, rồi đưa DOM về description đó khi có commit liên quan.

Phần này xây mental model nền cho toàn bộ series. Kết thúc bài, bạn sẽ phân biệt được component, React element và DOM node; biết value nào nên thuộc React, DOM hay server-state layer; và chẩn đoán được bốn failure mode xuất phát từ ownership sai.

Code được kiểm tra với react@19.2.8, react-dom@19.2.8 và TypeScript 5.9.3 trong strict mode. Bạn chỉ cần biết JavaScript/TypeScript cơ bản và DOM. Nếu chưa có project React, phần Vite mental model và project đầu tiên cung cấp setup đầy đủ; bài này không lặp lại toolchain.

1. Từ symptom “DOM và state lệch nhau” tới một invariant

Giả sử màn hình đang chọn issue PAY-101. Có ba representation liên quan:

  • selectedId trong React state là PAY-101;
  • React output mô tả title “Checkout không giữ mã giảm giá”;
  • DOM hiện chứa một <p> với title đó.

Một script bên ngoài đổi textContent của <p>. Chỉ representation thứ ba thay đổi; state và React output vẫn giữ giá trị cũ. Hệ thống đã có hai source of truth:

React state ──render──► expected UI
     │                     │
     │                     └──commit──► DOM
     │                                    ▲
     └──────── external script ────────────┘
                         second writer

Invariant React cố giữ là:

Với một root do React quản lý, UI đã commit phải tương ứng với render output mới nhất của root đó.

Invariant này không nói mọi DOM mutation sẽ bị sửa ngay. React không theo dõi từng lệnh do code ngoài thực hiện. Nếu lần render sau cho cùng prop và reconciliation kết luận node không cần update, mutation ngoài có thể còn nằm đó. Nhưng khi một commit cần cập nhật chính node/prop ấy, React sẽ ghi output của nó và mutation ngoài biến mất.

Đây là chi tiết quan trọng. Câu “React luôn ghi đè DOM ở lần render kế” vừa quá mạnh vừa khó kiểm chứng. Câu chính xác hơn là: đừng để hai hệ thống cùng sở hữu một phần DOM; khi React commit thay đổi ở đó, output React là phía thắng.

Browser vẫn là nơi DOM thật tồn tại. Nếu muốn đi sâu từ HTML source tới cây Document sống trong bộ nhớ, đọc HTML parsing và DOM. Trong bài này, DOM là host output; câu hỏi chính là React tạo output đó từ đâu.

2. Mental model: UI = f(state), element là description

Một component React nên được đọc như một phép tính:

props + state + context


   component render


 React element tree
   (description)


 reconciliation


     DOM commit

Ký hiệu quen thuộc là:

UI = f(props, state, context)

Đây là mental model, không phải chữ ký API. “Cùng input, cùng output” cũng không có nghĩa hai lần render phải trả về cùng một object reference. Hai React element có thể là hai object khác nhau nhưng mô tả cùng UI. Điều cần giữ là component không làm thay đổi object/variable đã tồn tại trước render và không phụ thuộc vào input ẩn như clock hay counter global.

React element là một value nhẹ mô tả điều React cần render. Ví dụ <IssueBadge status="open" /> mô tả:

  • component type là IssueBadge;
  • prop status"open";
  • chưa có DOM <span> nào được tạo chỉ vì expression này tồn tại.

Trong quá trình render, React gọi component để nhận description tiếp theo. Khi đi tới host element như <span>, description mới nói đến primitive mà React DOM có thể commit. API createElement trong tài liệu React gọi element là “lightweight description” và yêu cầu coi element cùng props là immutable.

Tại sao cần purity nghiêm như vậy? Vì React có thể gọi render để tính toán mà chưa commit kết quả. Ở các phần sau, work còn có thể được restart hoặc bỏ nếu input mới ưu tiên hơn. Nếu render gửi request, tăng biến global hoặc sửa DOM, việc “tính thử” đã gây hậu quả mà React không thể rollback.

Event handler thì khác. Click là một sự kiện đã xảy ra; handler có thể yêu cầu đổi state. State mới kích hoạt phép tính UI mới, rồi React quyết định thay đổi host output nào. Ranh giới này cho ta rule đầu tiên:

Render mô tả. Event handler yêu cầu thay đổi. Effect đồng bộ với external system. Đừng trộn ba vai trò.

Effects sẽ được mổ ở Phần 06. Hiện tại, chỉ cần nhận diện code trong component body phải là phép tính pure.

3. Component, element tree và DOM tree là ba lớp khác nhau

Ba lớp thường bị gọi chung là “component tree”, dẫn tới bug khi debug:

LớpNó chứa gìAi tạo/quản lýBạn dùng để làm gì
ComponentFunction/type và boundary theo domainApplication code + ReactChia behavior và ownership
React element treeDescription: type, props, children, keyRender outputNói UI mong muốn là gì
DOM treeDocument, Element, Text, event listenerBrowser; React DOM commitPaint, focus, selection, platform API

Một component không nhất thiết tạo đúng một DOM node. Nó có thể trả fragment, null, một component khác hoặc cả subtree. Ngược lại, một DOM node không mang đầy đủ abstraction domain của component đã tạo ra nó.

Phân lớp này cũng giải thích vì sao React không đồng nghĩa với DOM. Component và element model có thể được một renderer khác đưa tới host primitive khác; series này dùng React DOM vì target là web. Khi debug, hãy hỏi lỗi nằm ở description hay host output. Nếu title đã sai ngay trong render output, sửa state/props/calculation. Nếu description đúng nhưng focus, selection hoặc layout sai, điều tra commit và browser API. Trộn hai tầng thường tạo bản sửa “thêm state để ép DOM” dù nguồn lỗi thật là focus timing, hoặc “sửa textContent” dù source of truth thật nằm trong state.

Element cũng không phải snapshot để application lưu rồi mutate dần như một template AST tự quản. Application tạo description mới từ input mới; React chịu trách nhiệm so nó với work trước. Contract đó cho phép component code tập trung vào UI cần là gì, nhưng đổi lại bạn không được lén thay input/output cũ sau khi đã đưa cho React.

Running example bắt đầu bằng một dataset nhỏ. ID là domain identity; status là union đóng để compiler buộc ta xử lý đủ trường hợp.

examples/react-19-lab/src/part-01/issues.ts

export type IssueStatus = 'open' | 'in-progress' | 'closed';

export interface Issue {
  id: string;
  title: string;
  status: IssueStatus;
}

export const initialIssues = [
  {
    id: 'PAY-101',
    title: 'Checkout không giữ mã giảm giá',
    status: 'open',
  },
  {
    id: 'PAY-102',
    title: 'Payment callback bị xử lý hai lần',
    status: 'in-progress',
  },
  {
    id: 'PAY-103',
    title: 'Biên lai thiếu mã giao dịch',
    status: 'closed',
  },
] as const satisfies readonly Issue[];

IssueBadge chỉ ánh xạ status sang label. IssueList nhận data, selection và capability onSelect; nó không tự phát minh thêm source of truth.

examples/react-19-lab/src/part-01/IssueList.tsx

import type { Issue, IssueStatus } from './issues';

interface IssueBadgeProps {
  status: IssueStatus;
}

const statusLabel = {
  open: 'Mở',
  'in-progress': 'Đang xử lý',
  closed: 'Đã đóng',
} as const satisfies Record<IssueStatus, string>;

export function IssueBadge({ status }: IssueBadgeProps) {
  return <span data-status={status}>{statusLabel[status]}</span>;
}

interface IssueListProps {
  issues: readonly Issue[];
  selectedId: string;
  onSelect: (id: string) => void;
}

export function IssueList({ issues, selectedId, onSelect }: IssueListProps) {
  return (
    <ul aria-label="Danh sách issue">
      {issues.map((issue) => (
        <li key={issue.id}>
          <button
            type="button"
            aria-pressed={issue.id === selectedId}
            onClick={() => onSelect(issue.id)}
          >
            <strong>{issue.id}</strong>
            <span>{issue.title}</span>
            <IssueBadge status={issue.status} />
          </button>
        </li>
      ))}
    </ul>
  );
}

Hãy đọc IssueBadge như công thức. Input "open" tạo description cho một <span> có label “Mở”. Component không tăng counter, không đọc Date.now(), không sửa statusLabel, không chạm DOM.

IssueList cũng là phép tính, dù nó chứa event handler. Tạo function () => onSelect(issue.id) trong render không phải side effect: function mới chỉ là value được đặt trong description. Side effect chỉ xảy ra sau khi browser phát click và React gọi handler.

key={issue.id} chưa được đào sâu ở đây. Phần 04 sẽ nối key với identity và state preservation. Phần Diffing & Reconciliation đã giải thích thuật toán cây, nên series này sẽ tập trung vào contract của React thay vì viết lại toàn bộ keyed diff.

4. Issue Triage v0: một writer cho vùng DOM thuộc React

Component root sở hữu selectedId. selectedIssue không cần state riêng vì có thể derive từ initialIssuesselectedId trong render.

examples/react-19-lab/src/part-01/IssueTriage.tsx

import { useState } from 'react';
import { IssueList } from './IssueList';
import { initialIssues } from './issues';

export function IssueTriage() {
  const [selectedId, setSelectedId] = useState<string>(initialIssues[0].id);
  const selectedIssue =
    initialIssues.find((issue) => issue.id === selectedId) ?? initialIssues[0];

  function tamperWithOwnedDOM() {
    const title = document.querySelector<HTMLElement>(
      '[data-selected-issue-title]'
    );

    if (title) {
      title.textContent = 'DOM đã bị sửa bên ngoài React';
    }
  }

  return (
    <main>
      <p className="eyebrow">Issue Triage · Part 01</p>
      <h1>UI là kết quả của state hiện tại</h1>

      <section className="panel" aria-labelledby="selected-heading">
        <h2 id="selected-heading">Issue đang chọn</h2>
        <p data-selected-issue-title>{selectedIssue.title}</p>
        <p>
          Source of truth: <code>{selectedId}</code>
        </p>
        <button type="button" onClick={tamperWithOwnedDOM}>
          Phá DOM thủ công
        </button>
      </section>

      <IssueList
        issues={initialIssues}
        selectedId={selectedId}
        onSelect={setSelectedId}
      />
    </main>
  );
}

tamperWithOwnedDOM cố ý là code xấu để tạo failure. Sau khi bấm nút, text DOM đổi nhưng selectedId không đổi. Bấm PAY-102 khiến handler gọi setSelectedId; render output mới mô tả title của PAY-102, và commit cập nhật node title.

Test khóa behavior quan sát được, không kiểm tra field internal của element hay Fiber:

examples/react-19-lab/src/part-01/IssueTriage.test.tsx

import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { IssueBadge } from './IssueList';
import { IssueTriage } from './IssueTriage';

afterEach(cleanup);

describe('Part 01 — UI as value', () => {
  it('giữ IssueBadge pure với cùng input', () => {
    const first = render(<IssueBadge status="open" />);
    const firstMarkup = first.container.innerHTML;
    first.unmount();

    const second = render(<IssueBadge status="open" />);

    expect(second.container.innerHTML).toBe(firstMarkup);
  });

  it('đưa DOM về output thuộc React khi state đổi', () => {
    render(<IssueTriage />);

    fireEvent.click(screen.getByRole('button', { name: 'Phá DOM thủ công' }));
    expect(screen.getByText('DOM đã bị sửa bên ngoài React')).toBeTruthy();

    fireEvent.click(
      screen.getByRole('button', {
        name: /PAY-102 Payment callback bị xử lý hai lần/,
      })
    );

    const selectedTitle = document.querySelector<HTMLElement>(
      '[data-selected-issue-title]'
    );
    expect(selectedTitle?.textContent).toBe(
      'Payment callback bị xử lý hai lần'
    );
  });
});

Test đầu không chứng minh purity bằng toán học; nó là regression signal cho output quan sát được. Static rule, review và Strict Mode ở development vẫn cần để phát hiện mutation/side effect. Test thứ hai chứng minh claim hẹp hơn: khi state đổi làm title prop đổi, commit đưa node đó về output mới.

Direct DOM API không bị cấm tuyệt đối. Focus, selection, measurement, media hay third-party widget đều cần bridge tới platform. Điều kiện là ownership rõ: dùng ref/Effect ở boundary, hoặc cho widget một container mà React không đồng thời quản lý children bên trong. Phần 11 và 06 sẽ xây hai escape hatch đó.

5. Decision table: React state, DOM state hay server state

“Đưa mọi thứ vào React state” cũng sai như “sửa DOM cho nhanh”. Mỗi value cần đúng owner:

ValueOwner mặc địnhLợi íchChi phí/rủi ro
Issue đang chọnReact stateUI derive nhất quán, dễ testUpdate đi qua render/commit
Title hiển thịDerived trong renderKhông có state trùngTính lại mỗi render
Giá trị input uncontrolledDOM/formÍt state wiringReact không có current value mỗi keystroke
Danh sách fetch từ APIServer-state cacheBiết stale/retry/cache lifecycleThêm cache policy và async states
Focus/selectionDOM qua refDùng đúng platform capabilityImperative, timing-sensitive
Third-party widgetExternal owner trong boundary riêngWidget tự quản lifecycleCần adapter và cleanup

Với IssueTriage, chỉ selectedId là essential local state. selectedIssue và title được derive. Copy cả object đang chọn vào một state khác tạo duplication: khi dataset đổi title, selection object cũ có thể stale.

Danh sách từ API là vấn đề khác. Server mới là authority; local component không tự biết data còn fresh, request nào thắng hay lúc nào retry. Bài TanStack Query — Mental Model đã phủ server state và cache lifecycle. Series này chỉ giữ ranh giới: React local state dành cho interaction/invariant thuộc client; remote data không nên bị biến thành useState + useEffect rải khắp component.

React đem lại một data flow dễ suy luận, nhưng bạn trả giá bằng kỷ luật:

  • render phải pure;
  • state update phải đi qua owner;
  • imperative integration cần boundary;
  • abstraction không loại bỏ DOM/browser cost bên dưới.

Nếu UI chỉ là một đoạn static HTML không thay đổi, React không tự tạo thêm giá trị. Nếu component cần encapsulate bằng browser primitive, Web Components cung cấp một ownership model khác. Quyết định dùng React bắt đầu từ change/interaction model, không từ việc JSX nhìn tiện.

6. Failure modes: symptom → cause → fix → verify

6.1 DOM hiển thị một giá trị, state giữ giá trị khác

Symptom: script đổi title thành công, nhưng title biến mất khi chọn issue khác; hoặc UI giữ mutation lạ cho tới một update không đoán trước.

Root cause: React và script ngoài cùng ghi vào node do React sở hữu. DOM đã trở thành source of truth thứ hai nhưng React không đọc nó trong render.

Fix: state/props làm owner duy nhất cho content. Nếu cần third-party code, tách container ownership và đồng bộ qua adapter có lifecycle rõ.

Verify: mutate node trong test, thực hiện state transition làm prop đổi, rồi assert output trở về canonical state. Đồng thời kiểm tra listener/widget được cleanup khi boundary unmount.

6.2 Component cho output khác với cùng input

Symptom: thứ tự label đổi khi unrelated component render; development log nhảy hai lần; server/client hoặc test output không ổn định.

Root cause: render đọc Date.now(), Math.random() hoặc tăng/mutate biến ngoài component. Input thật lớn hơn props/state/context nhưng bị giấu.

Fix: truyền value biến thiên thành prop nếu nó là dữ liệu; tạo nó trong event handler nếu nó gắn với action; đồng bộ external system trong Effect nếu thật sự cần.

Verify: render cùng explicit input nhiều lần và so output quan sát được; bật Strict Mode để mutation trong render dễ lộ hơn. Không sửa bằng cách tắt Strict Mode.

6.3 Giữ cả object derive và ID trong state

Symptom: list hiển thị title mới nhưng panel selected vẫn giữ title cũ.

Root cause: cùng fact được duplicate thành issuesselectedIssue. Update một bản không tự update bản còn lại.

Fix: giữ selectedId; tìm object hiện tại từ dataset trong render. Nếu ID không còn tồn tại, định nghĩa fallback/reset semantics rõ.

Verify: cập nhật title trong dataset và assert panel đổi theo mà không có Effect đồng bộ hai state.

6.4 Nhầm React element với DOM node

Symptom: gọi .focus(), đọc .textContent hoặc dùng querySelector trên value JSX rồi gặp type/runtime error.

Root cause: <IssueBadge /> tạo description cho React, không tạo HTMLSpanElement ngay tại expression.

Fix: render element qua React; khi thật sự cần DOM capability, lấy node qua typed ref tại boundary. Không đọc cấu trúc private của React element để tìm DOM.

Verify: TypeScript phân biệt ReactElement với HTMLElement; integration test focus/selection trên node đã commit, không trên element description.

Bài tập

Bài 1 — Thiết kế PriorityBadge như một phép tính pure

Tạo union IssuePriority = 'low' | 'medium' | 'high' và component ánh xạ mỗi priority sang label cùng data-priority. Không đọc clock, random, DOM hoặc mutable module state trong render.

Definition of done:

  • strict typecheck buộc mapping đủ ba priority;
  • cùng prop cho cùng accessible text/attribute;
  • test không phụ thuộc object reference của React element.

Hint: dùng satisfies Record<IssuePriority, string> để compiler kiểm tra mapping, giống statusLabel.

Bài 2 — Lập ownership map trước khi thêm state

Thêm search text, current page, fetched issue list, selected issue, input draft và focus target vào một bảng. Với mỗi value, ghi authority, lifetime, consumer, cách restore sau reload và owner đề xuất.

Definition of done:

  • mỗi value có đúng một source of truth;
  • derived value không được lưu trùng nếu tính được trong render;
  • remote data không bị gắn nhãn local state chỉ vì component đang đọc nó;
  • focus được xem là DOM capability, không phải domain data.

Hint: nếu hai nơi có quyền sửa cùng fact mà không có protocol đồng bộ, owner chưa được thiết kế xong.

Tiêu chí hoàn thành

  • Bạn giải thích được component, React element và DOM node là ba lớp khác nhau.
  • Bạn mô tả được pipeline input → render description → reconciliation → commit mà không gọi React là template engine.
  • Bạn nhận ra side effect trong render và chuyển nó về event/Effect boundary phù hợp.
  • Bạn chọn một source of truth cho selection và derive object/title.
  • Bạn giải thích được vì sao direct DOM mutation có thể tồn tại tạm thời nhưng vẫn không phải owner an toàn.
  • Bạn chọn React state, DOM state hay server-state layer bằng trade-off cụ thể.

Phần tiếp theo

Phần này cố ý coi JSX như hộp đen tạo description. Ở Phần 02 — JSX → JSX runtime → element tree, ta mở hộp đó: xem compiler phát gì, type/props/children nằm ở đâu, vì sao key là special và tại sao browser không thể chạy JSX trực tiếp.

Nguồn chính thức