useRef & the DOM
ref คือกล่องที่อยู่ข้าม render
หัวข้อที่มีชื่อว่า “ref คือกล่องที่อยู่ข้าม render”useRef ให้ container ที่ mutable — object { current: value } — ที่ React เก็บไว้ข้าม render แต่ ไม่เฝ้าดู การเปลี่ยน ref.current ไม่ trigger re-render
const ref = useRef(0);ref.current += 1; // mutate freely — NO re-render happensนั่นคือความต่างทั้งหมดจาก state:
useState | useRef | |
|---|---|---|
| อยู่ข้าม re-render | ✅ | ✅ |
| เปลี่ยนแล้ว re-render | ✅ ใช่ | ❌ ไม่ |
| อ่านตอน render | ✅ (เป็น snapshot) | ⚠️ เลี่ยง (mutable, ไม่ reactive) |
| ใช้สำหรับ | data ที่แสดงใน UI | value ที่ต้องจำแต่ไม่ render |
ใช้ ref สำหรับสิ่งที่ UI ไม่ได้แสดงตรง ๆ: timer id, ค่าก่อนหน้าของบางอย่าง, counter ที่ mutable, scroll position หรือ reference ไปยัง DOM node
// Classic use: hold a timer id so you can clear it later.const intervalRef = useRef(null);function start() { intervalRef.current = setInterval(tick, 1000);}function stop() { clearInterval(intervalRef.current);}ref สำหรับเข้าถึง DOM
หัวข้อที่มีชื่อว่า “ref สำหรับเข้าถึง DOM”การใช้บ่อยที่สุดคือคว้า DOM node — เพื่อ focus input, วัดขนาด element หรือเรียก imperative browser API ผูก ref กับ host element ผ่าน attribute ref หลัง commit ref.current จะชี้ไปยัง DOM node จริง
function SearchBox() { const inputRef = useRef(null); return ( <> <input ref={inputRef} /> <button onClick={() => inputRef.current.focus()}>Focus</button> </> );}นี่คือ escape hatch — คุณกำลังก้าวออกนอก declarative model เพื่อคุยกับ DOM โดยตรง ใช้กับ focus, scroll, การวัดขนาด และการ integrate widget ที่ไม่ใช่ React อย่าใช้ทำสิ่งที่ state ทำได้
React 19: ref เป็นแค่ prop
หัวข้อที่มีชื่อว่า “React 19: ref เป็นแค่ prop”เมื่อก่อน การส่ง ref ผ่าน component ของคุณเองไปยัง DOM node ข้างในต้องห่อ component ด้วย forwardRef ใน React 19 ref เป็น prop ธรรมดา — คุณรับ ref แล้วส่งต่อได้เหมือน prop อื่น ๆ และ ไม่ต้องใช้ forwardRef สำหรับ code ใหม่แล้ว
// ✅ React 19: accept ref as a normal prop — no forwardRef.function MyInput({ placeholder, ref }) { return <input placeholder={placeholder} ref={ref} />;}
// Parent:const ref = useRef(null);<MyInput ref={ref} placeholder="Search" />;form เก่ายังใช้ได้ แต่ตอนนี้ react.dev พูดชัดกว่าคำว่า “legacy” เฉย ๆ — มันบอกกำหนดการจริง:
forwardRefwill be deprecated in a future release. — react.dev/reference/react/forwardRef
นั่นหนักกว่า “ไม่จำเป็นแล้ว” — เป็นแผน deprecate ที่ประกาศจริง สำหรับ component ใหม่ให้รับ ref เป็น prop ถ้าคุณดูแล code เก่าที่ยังใช้ forwardRef อยู่ มันยังทำงานได้ ไม่มี forcing function ให้ migrate วันนี้ แต่ component ใหม่ไม่ควรเพิ่มมันอีก
สำหรับกรณีที่พบน้อยกว่า ที่คุณอยากเปิดเผย imperative API แบบ custom (ไม่ใช่ raw DOM node) ให้ parent useImperativeHandle ให้คุณกำหนดได้ว่า ref เปิดเผยอะไรบ้าง — และใน React 19 มันประกอบกับ ref-เป็น-prop ได้ตรง ๆ ไม่ต้องห่อ forwardRef เลย:
import { useImperativeHandle, useRef } from 'react';
type FancyInputHandle = { focus: () => void; clear: () => void;};
function FancyInput({ ref }: { ref?: React.Ref<FancyInputHandle> }) { const inputRef = useRef<HTMLInputElement>(null); useImperativeHandle(ref, () => ({ focus: () => inputRef.current?.focus(), clear: () => { if (inputRef.current) inputRef.current.value = ''; }, })); return <input ref={inputRef} />;}
export default FancyInput;type-check ผ่านสะอาดด้วย tsc --strict ใน project React 19.3 — ไม่มี forwardRef ไม่มีการเล่น generic-parameter แยกต่างหาก:
$ tsc --strict --jsx react-jsx src/components/FancyInput.tsxNo errors foundRef callback cleanup function (React 19)
หัวข้อที่มีชื่อว่า “Ref callback cleanup function (React 19)”ref ยังเป็น callback แทน ref object ได้ — React จะเรียกมันพร้อม DOM node ตอน attach ตั้งแต่ React 19 callback นั้น return cleanup function ได้ ซึ่ง React จะเรียกตอน ref detach (หรือก่อนรัน callback instance ใหม่):
import { useRef } from 'react';
function TrackedList({ items }: { items: string[] }) { const itemsRef = useRef<HTMLLIElement[]>([]); return ( <ul> {items.map((item) => ( <li key={item} ref={(node) => { if (!node) return; itemsRef.current.push(node); // React 19: return a cleanup function from a ref callback. return () => { itemsRef.current = itemsRef.current.filter((n) => n !== node); }; }} > {item} </li> ))} </ul> );}
export default TrackedList;ก่อนมี cleanup form นี้ วิธีเดียวที่จะรู้ว่า node ถูก detach คือ React เรียก callback อีกครั้งด้วย null — ลืมง่ายมาก และจับคู่กับ setup ต่อ node ได้ไม่สะอาด พิสูจน์ด้านล่าง: cleanup รันทั้งตอน unmount และทุกครั้งที่ callback instance ใหม่ มาแทนตัวเก่า (React tear down ref เก่าก่อน attach ตัวถัดไป):
import { useRef, useState } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function List({ log }: { log: string[] }) { const itemsRef = useRef<HTMLLIElement[]>([]); const [items] = useState(['a', 'b']); return ( <ul> {items.map((item) => ( <li key={item} ref={(node) => { if (!node) return; itemsRef.current.push(node); log.push(`attach ${item}`); // React 19: a ref callback may return a cleanup function. return () => { itemsRef.current = itemsRef.current.filter((n) => n !== node); log.push(`detach ${item}`); }; }} > {item} </li> ))} </ul> );}
test('a ref callback cleanup function runs on unmount (React 19)', () => { const log: string[] = []; const { unmount } = render(<List log={log} />); expect(log).toEqual(['attach a', 'attach b']); log.length = 0; unmount(); expect(log).toEqual(['detach a', 'detach b']);});
test('a ref callback cleanup runs when a NEW callback instance is passed on re-render', () => { const log: string[] = []; function Box({ tag, log }: { tag: string; log: string[] }) { return ( <div ref={() => { log.push(`attach:${tag}`); return () => { log.push(`detach:${tag}`); }; }} /> ); } const { rerender } = render(<Box tag="v1" log={log} />); expect(log).toEqual(['attach:v1']); log.length = 0; // A brand new inline function is passed -> React tears down the old ref // (runs its cleanup) before attaching the new one. rerender(<Box tag="v2" log={log} />); expect(log).toEqual(['detach:v1', 'attach:v2']);});✓ a ref callback cleanup function runs on unmount (React 19)✓ a ref callback cleanup runs when a NEW callback instance is passed on re-render
Test Files 1 passed (1) Tests 2 passed (2)caveat ของ react.dev เองก็ยืนยันเทสต์ที่สองตรง ๆ: “When you pass a different ref callback, React will call the previous callback’s cleanup function if provided.” — ถ้าไม่มี cleanup function React จะ fallback ไปเรียก callback เก่าด้วย null แทน ซึ่ง docs บอกว่า จะถูกเอาออกในเวอร์ชันอนาคต เขียน cleanup form ให้ ref callback ทุกตัวที่คุณเขียนวันนี้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ref คือคำตอบของ React ต่อ “ฉันต้องการ value ที่อยู่นานกว่า render นี้ แต่ไม่อยากให้ React schedule งานเพราะมัน” state มีไว้สำหรับ value ที่ render function ขึ้นกับ; ref มีไว้สำหรับ value ที่อยู่ เคียงข้าง render function โดยไม่มีส่วนร่วมในมัน นี่คือเหตุผลที่การอ่านหรือเขียน ref.current ระหว่าง render (ไม่ใช่ใน event handler หรือ effect) เป็นบั๊กจริง ไม่ใช่แค่เรื่อง style — render ควรเป็น pure computation ของ “props/state → UI” และ ref เป็น mutable escape hatch ที่ไม่มีลำดับ ออกจาก computation นั้นตรง ๆ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
ref เป็น prop ธรรมดา (React 19) | ไม่ต้องห่อ forwardRef อ้อมน้อยลง ref forwarding อ่านเหมือน prop อื่น | ต้องใช้ React 19+; code ที่ target React เก่ายังต้อง forwardRef |
| Ref callback พร้อม cleanup (React 19) | จับคู่ attach/detach ต่อ node ถูกต้อง แม้ callback identity เปลี่ยน | ต้องเรียนรู้ function shape เพิ่มอีกแบบนอกจาก ref object ธรรมดา |
useImperativeHandle | เปิดเผย API ที่เล็กและตั้งใจ แทน raw DOM node | อ้อมเพิ่ม — คุ้มเฉพาะตอนที่ raw node เป็นสิ่งที่ไม่ควรเปิดเผย |
| อ่าน ref ระหว่าง render | ไม่มี | ทำให้ output ของ component ขึ้นกับ mutable state ที่ไม่ถูก track — ทำลาย purity ที่ compiler และ Strict Mode พึ่งพา |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
forwardRefเป็น legacy แต่เขียนต่อได้สบายใจ” react.dev ตอนนี้บอกแผน deprecate ชัดเจน ไม่ใช่แค่ “ไม่จำเป็นแล้ว” — component ใหม่ควรรับrefเป็น prop - “ref callback ที่ไม่ return อะไรก็แค่ไม่ทำอะไรเพิ่ม” วันนี้ยังทำงานได้ (React fallback ไปเรียกด้วย
nullตอน detach) แต่ fallback นั้นถูกระบุไว้ชัดว่าจะถูกเอาออก — เขียน cleanup function เสมอใน code ใหม่ - “อ่าน
ref.currentระหว่าง render ปลอดภัยเท่ากับอ่าน state” ไม่จริง: ref เป็น mutable และไม่มีลำดับเทียบกับ rendering ดังนั้น value ที่อ่านระหว่าง render อาจเก่าค้าง ไม่สอดคล้องกันภายใต้ double-render ของ Strict Mode หรือผิดง่าย ๆ ตอน render เดียวกันถูกเล่นซ้ำ - “
useImperativeHandleแทน prop ปกติได้” มันมีไว้สำหรับกรณีพิเศษที่เปิดเผย API แบบ imperative (focus(),scrollIntoView()) — การสื่อสารระหว่าง component ส่วนใหญ่ควรยังผ่าน prop กับ state ไม่ใช่ ref handle
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ component ที่ generate มาใช้ ref ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
ref.currentถูกอ่าน (หรือเขียน) ในตัว render body เองหรือเปล่า แทนที่จะอยู่ใน event handler หรือ effect?- component ห่อตัวเองด้วย
forwardRefทั้งที่รับrefเป็น prop ได้เลยหรือเปล่า (target React 19+)? - ref callback ที่ต้อง detach ให้สะอาด (เพิ่มเข้าลิสต์, subscribe observer) return cleanup function หรือพึ่ง null-callback fallback ที่ถูก deprecate แล้ว?
useImperativeHandleถูกใช้เปิดเผย DOM node ทั้งก้อน (ทำลายจุดประสงค์ของมัน) แทนที่จะเป็น method set เล็ก ๆ ที่ตั้งใจไว้หรือเปล่า?- ref ถูกใช้เก็บ value ที่จริง ๆ ถูกแสดงใน UI หรือเปล่า — สัญญาณว่าควรเป็น state แทน?
🐛 หา bug ในโค้ดนี้
function Clock() {const ref = useRef(0);ref.current = Date.now(); // written during renderconst value = ref.current; // read during renderreturn <span>{value}</span>;}เฉลย
ทั้งการเขียนและการอ่านเกิดขึ้นในตัว render body เอง ไม่ใช่ event handler หรือ effect lint rule ของ React เองสำหรับกรณีนี้ (react-hooks/refs ส่วนหนึ่งของ [email protected] ตัวที่สืบต่อจาก rule set react-hooks แบบเก่าและรู้จัก compiler) ปฏิเสธตรง ๆ เมื่อรันกับ pattern นี้จริง:
Error: Cannot access refs during render
React refs are values that are not needed for rendering. Refs should only beaccessed outside of render, such as in event handlers or effects. Accessinga ref value (the `current` property) during render can cause your componentnot to update as expected (https://react.dev/reference/react/useRef).ทางแก้คือย้ายทั้งการเขียนและการอ่านเข้าไปใน effect (หรือ event handler) แล้วเก็บอะไรก็ตามที่ UI ต้องแสดงไว้ใน state ไม่ใช่ ref — การ mutate ref มองไม่เห็นโดย scheduler ของ React ดังนั้น component ที่อ่าน ref ระหว่าง render อาจ render ค่าที่เก่าค้างหรือไม่สอดคล้องกันแบบเงียบ ๆ โดยเฉพาะภายใต้ render pass พิเศษของ Strict Mode หรือ memoization ของ React Compiler ที่ทั้งคู่สมมติว่า render เป็น pure function ของ props กับ state