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

useRef & the DOM

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:

useStateuseRef
อยู่ข้าม re-render✅✅
เปลี่ยนแล้ว re-render✅ ใช่❌ ไม่
อ่านตอน render✅ (เป็น snapshot)⚠️ เลี่ยง (mutable, ไม่ reactive)
ใช้สำหรับdata ที่แสดงใน UIvalue ที่ต้องจำแต่ไม่ 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);
}

การใช้บ่อยที่สุดคือคว้า 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 ทำได้

เมื่อก่อน การส่ง 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” เฉย ๆ — มันบอกกำหนดการจริง:

forwardRef will 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 เลย:

src/components/FancyInput.tsx
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.tsx
No errors found

ref ยังเป็น callback แทน ref object ได้ — React จะเรียกมันพร้อม DOM node ตอน attach ตั้งแต่ React 19 callback นั้น return cleanup function ได้ ซึ่ง React จะเรียกตอน ref detach (หรือก่อนรัน callback instance ใหม่):

src/components/TrackedList.tsx
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 ตัวถัดไป):

src/__tests__/ref-callback-cleanup.test.tsx
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 ทุกตัวที่คุณเขียนวันนี้

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

เมื่อ 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 render
const value = ref.current; // read during render
return <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 be
accessed outside of render, such as in event handlers or effects. Accessing
a ref value (the `current` property) during render can cause your component
not 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

ความต่างสำคัญระหว่าง useRef กับ useState คืออะไร?
อะไรคือการใช้ ref ที่เหมาะสม?
ใน React 19 คุณส่ง ref ผ่าน component ของคุณเองไปยัง DOM node ข้างในอย่างไร?
react.dev พูดถึงอนาคตของ forwardRef อย่างไร นอกจากเรียกมันว่า "legacy"?
function ที่ return มารันตอนไหน?
<li
  ref={(node) => {
    if (!node) return;
    list.push(node);
    return () => {
      list.splice(list.indexOf(node), 1);
    };
  }}
/>
component นี้ผิดตรงไหน?
function Clock() {
  const ref = useRef(0);
  ref.current = Date.now();
  const value = ref.current;
  return <span>{value}</span>;
}