ข้ามไปยังเนื้อหา

useId & Hook อื่น ๆ

hook ในตัวอีกห้าตัวปิดท้ายโมดูลนี้ แต่ละตัวแก้ปัญหาที่แคบและจำเพาะตัวเดียว — คุณจะหยิบมาใช้ส่วนใหญ่ไม่บ่อย แต่พอต้องใช้จริง ไม่มีอย่างอื่นทำแทนได้

flowchart TD
  q1{"Need a unique id for
 aria-* / label/input pairing?"} -->|yes| useId
  q1 -->|no| q2{"Reading from a store
outside React (Redux, browser API)?"}
  q2 -->|yes| useSyncExternalStore
  q2 -->|no| q3{"Want to keep showing OLD UI
while new input is expensive to render?"}
  q3 -->|yes| useDeferredValue
  q3 -->|no| q4{"Writing a CSS-in-JS library
that injects style tags?"}
  q4 -->|yes| useInsertionEffect
  q4 -->|no| useDebugValue["useDebugValue
(labeling your own custom hook
in React DevTools)"]
ต้องใช้ hook เล็ก ๆ ตัวไหน?

useId สร้าง id string ที่ unique และ stable สำหรับ component instance หนึ่งตัว ถูกออกแบบมาโดยเฉพาะให้ตรงกันระหว่าง HTML ที่ render จาก server กับ hydration pass ฝั่ง client ซึ่ง counter ที่เขียนมือหรือ Math.random() การันตีแบบนี้ไม่ได้

import { useId } from 'react';
function PasswordField() {
const passwordHintId = useId();
return (
<>
<label>
Password:
<input type="password" aria-describedby={passwordHintId} />
</label>
<p id={passwordHintId}>The password should contain at least 18 characters</p>
</>
);
}

การ hardcode id string ทำงานได้แค่กับ component instance เดียว render PasswordField สองครั้งแล้ว id ที่ hardcode ไว้จะชนกัน — HTML ผิด (id ซ้ำ) และการเชื่อม aria-describedby พัง เพราะ accessibility tree ของ browser เชื่อม id กับ element ได้แค่ตัวเดียว

src/__tests__/useid-duplicate-ids.test.tsx
import { useId } from 'react';
import { render, cleanup } from '@testing-library/react';
import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
// 🔴 Hardcoded id: every instance collides — invalid HTML (duplicate ids)
// and broken `aria-describedby` association for assistive tech.
function BadField() {
return (
<>
<input aria-describedby="hint" />
<p id="hint">Required</p>
</>
);
}
// ✅ useId: unique per component instance, stable across the component's life.
function GoodField() {
const hintId = useId();
return (
<>
<input aria-describedby={hintId} />
<p id={hintId}>Required</p>
</>
);
}
test('hardcoded ids collide when a component renders more than once', () => {
const { container } = render(
<>
<BadField />
<BadField />
</>,
);
const ids = [...container.querySelectorAll('p')].map((p) => p.id);
expect(ids).toEqual(['hint', 'hint']); // duplicate — invalid HTML
});
test('useId gives each instance a distinct id', () => {
const { container } = render(
<>
<GoodField />
<GoodField />
</>,
);
const ids = [...container.querySelectorAll('p')].map((p) => p.id);
expect(new Set(ids).size).toBe(2); // no collision
});
✓ hardcoded ids collide when a component renders more than once
✓ useId gives each instance a distinct id
Test Files 1 passed (1)
Tests 2 passed (2)

ถ้า render React app อิสระมากกว่าหนึ่งตัวในหน้าเดียวกัน ให้ส่ง option identifierPrefix ให้ createRoot/hydrateRoot (และให้ตรงกันที่ renderToPipeableStream ฝั่ง server) เพื่อให้ id ของสอง app ชนกันไม่ได้เลย:

const root1 = createRoot(document.getElementById('root1'), { identifierPrefix: 'app1-' });
const root2 = createRoot(document.getElementById('root2'), { identifierPrefix: 'app2-' });

อย่า ใช้ useId สร้าง key ให้ list — มันมีไว้สำหรับ accessibility attribute id บนจำนวน element ที่คงที่ต่อ component instance ไม่ใช่ identity ต่อ item ใน list ที่เปลี่ยนแปลงได้ (ใช้ data id ที่ stable ของแต่ละ item เองสำหรับกรณีนั้น)

เมื่อ state อยู่ใน store ที่ไม่ใช่ของ React เอง (global variable, browser API, library อย่าง Redux หรือ subscription model ข้างใต้ Zustand) useSyncExternalStore คือ hook ที่ subscribe component เข้ากับมันอย่างถูกต้อง — รวมถึง getServerSnapshot สำหรับ SSR และการป้องกัน “tearing” (component ต่างกันเห็น value ต่างกันจาก store เดียวกันใน render เดียว) บทพี่น้องในโมดูลนี้ External Stores ครอบคลุม API เต็ม, ปัญหา tearing และ selector pitfall คลาสสิกแบบละเอียด — นี่คือย่อหน้าเดียวที่ชี้ทาง ไม่ใช่การพูดซ้ำ

useDeferredValue ให้ส่วนที่แพงของ UI ล้าหลังการอัปเดตที่ urgent แทนที่จะบล็อกมัน ตั้งแต่ React 19 มันรับ argument ที่สอง initialValue ใช้แค่ตอน render แรก ของ component เท่านั้น:

import { useState, useDeferredValue } from 'react';
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query, ''); // '' only on first render
// ...
}

ถ้าไม่มี initialValue react.dev บอกตรง ๆ ว่า hook “will not defer during the initial render” — render แรกจะได้ value ตรง ๆ เลย ถ้ามีมัน render แรกจะได้ initialValue แทน ทำให้แสดง placeholder ทันทีก่อนที่จะพิมพ์ query จริงเลยด้วยซ้ำ

// src/__tests__/useid-and-other-hooks.test.tsx (excerpt)
test('useDeferredValue(value, initialValue) uses initialValue on the FIRST render, not a deferred empty value', () => {
const seenOnFirstRender: string[] = [];
function Search({ query }: { query: string }) {
const deferred = useDeferredValue(query, 'placeholder');
seenOnFirstRender.push(deferred);
return <output>{deferred}</output>;
}
render(<Search query="hello" />);
// react.dev: "During the initial render, the returned deferred value will
// be initialValue... If omitted, useDeferredValue will not defer during
// the initial render." The first render shows the placeholder, not 'hello'.
expect(seenOnFirstRender[0]).toBe('placeholder');
});
✓ useDeferredValue(value, initialValue) uses initialValue on the FIRST render, not a deferred empty value
Test Files 1 passed (1)
Tests 1 passed (1)

useDebugValue ไม่เพิ่มพฤติกรรม runtime อะไรเลย — มันมีไว้แค่ให้ React DevTools โชว์ label ที่อ่านง่ายข้าง state ของ custom hook แทนที่จะเป็น list ของ raw value ที่อ่านไม่รู้เรื่อง:

function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
// ...
useDebugValue(isOnline ? 'Online' : 'Offline');
return isOnline;
}

สำหรับ value ที่ format เป็น string ที่อ่านง่ายแล้วแพง ให้ส่ง function format ตัวที่สอง — React จะเรียกมันแค่ตอนที่ DevTools กำลัง inspect hook นั้นจริง ๆ ไม่ใช่ทุก render:

useDebugValue(date, (d) => d.toDateString());

คุณจะเรียกอันนี้แค่จากใน custom hook ที่คุณเขียนเองเท่านั้น — มันไม่มีอะไรให้เพิ่มในตัว component ธรรมดา

useInsertionEffect ยิงก่อนทุก layout effect โดยเฉพาะเพื่อให้ CSS-in-JS library inject <style> tag เข้า DOM ก่อนที่ useLayoutEffect ของ component คุณจะพยายามอ่าน layout ที่ขึ้นกับ style เหล่านั้น พิสูจน์ด้านล่างเทียบกับ useLayoutEffect:

// src/__tests__/useid-and-other-hooks.test.tsx (excerpt)
test('useInsertionEffect fires before useLayoutEffect', () => {
const log: string[] = [];
function Widget() {
useInsertionEffect(() => {
log.push('insertion');
});
useLayoutEffect(() => {
log.push('layout');
});
return null;
}
render(<Widget />);
expect(log).toEqual(['insertion', 'layout']);
});
✓ useInsertionEffect fires before useLayoutEffect
Test Files 1 passed (1)
Tests 1 passed (1)

react.dev พูดตรงกลุ่มเป้าหมายชัดเจน: hook นี้ “primarily intended for CSS-in-JS library authors” มันยังมาพร้อม caveat ที่คมมาก — update state จากในนั้นไม่ได้, ref ยังไม่ attach ตอนมันรัน และรันแค่ฝั่ง client เท่านั้น application code ควรใช้ useEffect หรือ useLayoutEffect แทน ถ้าคุณไม่ได้กำลังสร้าง styling library คุณแทบไม่ต้องใช้ useInsertionEffect เลย

ห้า hook นี้ไม่ใช่ของกองรวมแบบสุ่ม — แต่ละตัวมีอยู่เพราะมีขอบเขตจำเพาะที่ต้องข้าม ซึ่ง hook หลัก (useState, useEffect, useRef) ไม่ได้ถูกออกแบบมาสำหรับมัน: useId ข้ามขอบเขต server/client สำหรับการสร้าง identifier, useSyncExternalStore ข้ามขอบเขต React/non-React state, useDeferredValue ข้ามขอบเขต priority ของ rendering แบบ urgent/non-urgent, useInsertionEffect ข้ามขอบเขตเวลา styles-ก่อน-layout และ useDebugValue ข้ามขอบเขต runtime/การ inspect ของ DevTools ถ้าคุณไม่ได้ยืนอยู่ที่หนึ่งในห้าขอบเขตนี้ คุณไม่ต้องใช้ hook นั้น

Hookประโยชน์ต้นทุน
useIdการันตีไม่มี id ชนกันข้าม instance หรือข้าม server/client hydrationเรียก hook เพิ่มอีกตัวสำหรับสิ่งที่ hardcoded string “ดูเหมือน” ทำแทนได้
useSyncExternalStoresubscription ที่ถูกต้อง ไม่ tear กับ state นอก React พร้อม SSR supportsetup มากกว่าการอ่าน global variable ตรง ๆ (แต่ความตรงนั้นแหละคือสาเหตุของ tearing)
useDeferredValue(value, initialValue)first paint ที่ทันทีและสมเหตุสมผลก่อนที่ deferring จะเริ่มทำงานต้องจำ argument เพิ่มอีกตัว มีผลแค่ render แรกเท่านั้น
useInsertionEffectstyle มาถึงก่อนที่ layout effect จะอ่านมันมีประโยชน์แค่ใน styling library เป็น tool ที่ผิดสำหรับ effect ของ app ทั่วไป
  • “hardcoded id string โอเคตราบใดที่ render component แค่ครั้งเดียว” จริงจนกว่าจะมีคน reuse component ครั้งที่สองในหน้าเดียวกัน — พิสูจน์แล้วด้านบน นั่นคือการชนกันแบบ HTML ผิดทันที
  • “useId มีไว้สำหรับ React key ใน list” ไม่ใช่ — มันมีไว้สำหรับ accessibility attribute id บนชุด element ที่คงที่ต่อ instance list key ต้องการ identity ต่อ item ตาม data ไม่ใช่ useId
  • “useDeferredValue ที่ไม่มี initialValue ก็ยัง defer ตอน render แรกเหมือนกัน” react.dev บอกตรงข้าม: ถ้าไม่มี initialValue hook จะไม่ defer เลยใน render แรกนั้น — มันเริ่ม defer แค่ตอนอัปเดตครั้งถัดไป
  • “useInsertionEffect ก็แค่ useEffect ที่เร็วกว่าสำหรับใช้งานทั่วไป” มันเป็น tool แคบสำหรับ style injection ที่มีข้อจำกัดจริง (update state ไม่ได้, ref ยังไม่ attach) — หยิบมาใช้ใน application code ทั่วไปแทบจะผิดเสมอ

เมื่อ code ที่ generate มาใช้ (หรือควรใช้แต่ไม่ใช้) hook เล็ก ๆ เหล่านี้ ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:

  • accessibility id (aria-describedby, คู่ htmlFor/id) ถูก hardcode เป็น string literal แทนที่จะ generate ด้วย useId หรือเปล่า?
  • useId ถูกใช้ key list ที่ render ด้วย .map() แทนที่จะใช้ identifier ที่ stable ของแต่ละ item เองหรือเปล่า?
  • component อ่านจาก external store (module-level variable, window, library ที่ไม่ใช่ React) ตรง ๆ ระหว่าง render แทนที่จะผ่าน useSyncExternalStore เสี่ยง tearing หรือ mismatch ระหว่าง server/client หรือเปล่า?
  • useDeferredValue ถูกใช้โดยไม่ได้พิจารณาว่า render แรกควรโชว์ initialValue หรือ value จริงทันทีหรือเปล่า?
  • useInsertionEffect ถูกใช้ทำอะไรที่ไม่ใช่ style injection — สัญญาณว่าผู้เขียนหยิบมันมาโดยไม่เข้าใจจุดประสงค์และข้อจำกัดที่แคบของมันหรือเปล่า?

🐛 หา bug ในโค้ดนี้

function PasswordField() {
return (
<>
<label>
Password:
<input type="password" aria-describedby="password-hint" />
</label>
<p id="password-hint">At least 18 characters</p>
</>
);
}
เฉลย

id "password-hint" เป็น string literal ไม่ได้ generate ต่อ instance render <PasswordField /> มากกว่าหนึ่งครั้งในหน้าเดียวกัน — เช่นฟอร์ม login กับฟอร์ม signup เปิดใน modal stack ทั้งคู่ — แล้วทุก instance แชร์ id เดียวกันเป๊ะ นั่นคือ HTML ผิด (id ซ้ำไม่ได้รับอนุญาต) และทำลายการเชื่อม accessibility ของทุก instance หลังตัวแรก: aria-describedby="password-hint" ชี้ assistive technology ไปที่ <p id="password-hint"> ตัวแรกในเอกสารได้เท่านั้น ไม่ใช่ hint ของแต่ละ field เอง พิสูจน์แล้วในโพรบ: สอง instance ของ component ที่ id เป็น hardcode render <p> สองตัวที่มี id เดียวกันคือ "hint" ในขณะที่สอง instance ของ component เดียวกันที่ใช้ useId ได้ id ที่ต่างกันสองตัว ทางแก้คือสิ่งที่บทนี้เปิดไว้ตั้งแต่ต้น — แทนที่ string literal ด้วย const passwordHintId = useId(); แล้วใช้มันทั้งใน aria-describedby และ id

useId แก้ปัญหาอะไรที่ hardcoded id string แก้ไม่ได้?
ควรใช้ useId generate key ให้ list ที่ render ด้วย .map() ไหม?
เมื่อไรควรหยิบ useSyncExternalStore แทนการอ่าน module-level variable ตรง ๆ ระหว่าง render?
argument initialValue ของ useDeferredValue(value, initialValue) ทำอะไร?
useInsertionEffect มีไว้สำหรับใคร?
ถ้า render component นี้สองครั้งในหน้าเดียวกันจะเกิดอะไรขึ้น?
function PasswordField() {
  return (
    <>
      <input aria-describedby="hint" />
      <p id="hint">Required</p>
    </>
  );
}