useEffect & Effects
effect มีไว้ทำอะไร
หัวข้อที่มีชื่อว่า “effect มีไว้ทำอะไร”useEffect เป็น hook ที่คนเข้าใจผิดมากที่สุด ไม่ใช่ถังรวม “รัน code หลัง render” งานเดียวของตัวเองคือ sync component ของคุณกับ external system — สิ่งที่อยู่นอกการควบคุมของ React: network connection, browser API, third-party widget, subscription, timer
useEffect(() => { const connection = createConnection(roomId); // set up external system connection.connect(); return () => connection.disconnect(); // clean it up}, [roomId]); // re-sync when roomId changesอ่านว่า: “ทำให้ connection sync กับ roomId” เมื่อ roomId เปลี่ยน React รัน cleanup ของค่าเก่า แล้วรัน effect ของค่าใหม่ เมื่อ component unmount React รัน cleanup ครั้งสุดท้าย
Dependency และ cleanup
หัวข้อที่มีชื่อว่า “Dependency และ cleanup”dependency array ระบุ reactive value ทุกตัว (prop, state หรือค่า derived) ที่ effect อ่าน React รัน effect ใหม่เมื่อค่าใดค่าหนึ่งเปลี่ยน
[dep1, dep2]— รันใหม่เมื่อค่าเหล่านี้เปลี่ยน[]— รันครั้งเดียวตอน mount, cleanup ตอน unmount- ไม่ใส่ — รันหลัง ทุก render (มักไม่ใช่สิ่งที่ต้องการ)
cleanup function ที่คุณ return ต้อง undo สิ่งที่ effect ตั้งไว้ — disconnect, clear timer, remove listener การข้าม cleanup คือแหล่งบั๊กอันดับหนึ่ง: connection ซ้ำ, leak และ listener ที่ยิงหลัง unmount
flowchart LR dep["dependency changes"] --> cleanup["run cleanup for old value"] cleanup --> setup["run effect for new value"] unmount["component unmounts"] --> final["run final cleanup"]
อย่าสู้กับ linter ที่เติม dependency array ให้ ถ้า dependency ไม่สะดวก วิธีแก้แทบไม่เคยเป็นการโกหก array — แต่เป็นการ restructure (ย้ายค่าเข้าไปใน effect, ใช้ updater, หรือ memoize)
คุณอาจไม่ต้องใช้ effect
หัวข้อที่มีชื่อว่า “คุณอาจไม่ต้องใช้ effect”นี่คือบทเรียนสำคัญที่สุดในทั้งโมดูล effect มีไว้สำหรับ external system ถ้าคุณใช้ effect เพื่อ transform data สำหรับ render หรือเพื่อ ตอบสนอง user event คุณแทบจะแน่นอนว่าไม่ต้องใช้ effect — และการฝืนใช้ทำให้ code ช้าลงและมีบั๊กมากขึ้น
อย่า sync derived state ด้วย effect ให้คำนวณตอน render
// 🔴 Avoid: redundant state + an effect to keep it in sync.const [fullName, setFullName] = useState('');useEffect(() => { setFullName(firstName + ' ' + lastName);}, [firstName, lastName]);
// ✅ Good: derive it during render — no state, no effect.const fullName = firstName + ' ' + lastName;สำหรับ derivation ที่ แพง ให้ห่อด้วย useMemo — ยังไม่ต้องใช้ effect ไม่ต้องมี state เพิ่ม
// ✅ Cached derivation, no effect.const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);อย่าใช้ effect จัดการ user event logic ที่ควรรันเพราะ user ทำอะไรบางอย่างควรอยู่ใน event handler ที่คุณรู้แน่ชัดว่าเกิดอะไรขึ้น
// 🔴 Avoid: reacting to a state change with an effect to send analytics.useEffect(() => { if (submitted) post('/analytics', { event: 'submit' });}, [submitted]);
// ✅ Good: do it in the handler that caused the submit.function handleSubmit() { setSubmitted(true); post('/analytics', { event: 'submit' });}เทสต์เร็ว ๆ: “นี่กำลัง sync กับสิ่งที่อยู่นอก React หรือเปล่า?” ถ้าใช่ ใช้ effect ถ้าเป็นการ derive data หรือตอบสนอง interaction ไม่ต้องใช้ effect
ลำดับการรัน effect: mount, unmount และตอน dependency เปลี่ยน
หัวข้อที่มีชื่อว่า “ลำดับการรัน effect: mount, unmount และตอน dependency เปลี่ยน”พอ tree มี effect มากกว่าหนึ่งระดับ ลำดับ ที่มันรันเริ่มสำคัญ — parent effect ที่สมมติว่า child effect รันไปแล้ว (หรือกลับกัน) จะอ่าน state เก่าค้าง ลำดับจริงของ React พิสูจน์ไว้ด้านล่าง:
- ตอน mount passive effect commit แบบ bottom-up: child effect ทุกตัวรันก่อน parent ของมัน
- ตอน unmount cleanup รันแบบ top-down — กลับด้านกับตอน mount: cleanup ของ parent รันก่อน cleanup ของ child
- ตอน dependency เปลี่ยน cleanup ของ effect ตัวเดียวกัน (สำหรับค่าเก่า) รันก่อน setup ใหม่ของมันเอง (สำหรับค่าใหม่) เสมอ — ไม่เคย batch กับ effect ตัวอื่น
import { useEffect } from 'react';import { render, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
function Child({ log }: { log: string[] }) { useEffect(() => { log.push('child effect'); return () => { log.push('child cleanup'); }; }); return <span>child</span>;}
function Parent({ log }: { log: string[] }) { useEffect(() => { log.push('parent effect'); return () => { log.push('parent cleanup'); }; }); return <Child log={log} />;}
test('mount runs child effects before parent effects', () => { const log: string[] = []; render(<Parent log={log} />); // React commits passive effects bottom-up: children first, then parent. expect(log).toEqual(['child effect', 'parent effect']);});
test('unmount runs parent cleanup before child cleanup (reverse of mount order)', () => { const log: string[] = []; const { unmount } = render(<Parent log={log} />); log.length = 0; unmount(); expect(log).toEqual(['parent cleanup', 'child cleanup']);});
test('on a dependency change, an effect\'s own cleanup runs before its new setup', () => { const log: string[] = []; function Timer({ id }: { id: number }) { useEffect(() => { log.push(`setup ${id}`); return () => { log.push(`cleanup ${id}`); }; }, [id]); return null; } const { rerender } = render(<Timer id={1} />); log.length = 0; rerender(<Timer id={2} />); expect(log).toEqual(['cleanup 1', 'setup 2']);});รันจริงใน probe Vite + React 19.3 + Vitest + React Testing Library:
✓ mount runs child effects before parent effects✓ unmount runs parent cleanup before child cleanup (reverse of mount order)✓ on a dependency change, an effect's own cleanup runs before its new setup
Test Files 1 passed (1) Tests 3 passed (3)useEffectEvent: แก้ปัญหา stale closure (React 19.2)
หัวข้อที่มีชื่อว่า “useEffectEvent: แก้ปัญหา stale closure (React 19.2)”ย้อนกลับไปที่ตัวอย่าง connection สมมติว่า effect ต้อง log บางอย่างที่เปลี่ยนบ่อย — เช่น theme — โดยไม่ต้อง reconnect ใหม่ทุกครั้งที่ theme เปลี่ยน:
function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(roomId); connection.on('connected', () => { showNotification('Connected!', theme); // reads theme }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); // 🔴 now reconnects every time theme changes too}ใส่ theme ใน deps แล้ว connection จะ reset ทุกครั้งที่สลับ theme — ทั้งที่การ reconnect ไม่เกี่ยวอะไรกับ theme เลย ถ้าไม่ใส่ linter จะเตือนบั๊กจริง: showNotification จะ closure ค้าง theme เก่า จากตอนที่ effect รันครั้งล่าสุด ก่อน 19.2 ทางแก้คือ restructure แบบอึดอัด (ใช้ ref สะท้อนค่าล่าสุด) useEffectEvent คือ tool ที่ React สร้างมาเพื่อกรณีนี้โดยเฉพาะ: มันให้คุณแยกส่วนที่ไม่ reactive ของ effect ออกมาเป็น “Effect Event” ที่เห็น prop/state ล่าสุดเสมอ โดยไม่ต้องเป็น reactive dependency
import { useEffect, useEffectEvent, useState } from 'react';
function Timer({ onTick }: { onTick: () => void }) { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1);
// useEffectEvent: reads the LATEST `increment` without being reactive. const onTickEvent = useEffectEvent(() => { setCount((c) => c + increment); onTick(); });
useEffect(() => { const id = setInterval(onTickEvent, 1000); return () => clearInterval(id); // Empty deps: the interval is set up once. `increment` is read through // useEffectEvent, so it is intentionally NOT a dependency here. }, []);
return ( <div> <output data-testid="count">{count}</output> <button onClick={() => setIncrement((i) => i + 1)}>bump increment</button> </div> );}
export default Timer;function ที่ useEffectEvent คืนมา ไม่ reactive — identity ของมัน stable และการเรียกมันจะรันด้วย prop/state ล่าสุดของ component เสมอ ราวกับถูกประกาศไว้ตรงนั้นใน effect เอง นั่นคือเหตุผลที่ห้ามใส่มันใน dependency array ของ effect เด็ดขาด — การใส่จะทำลายจุดประสงค์ของมันเอง (ทำให้ effect รันใหม่ทุก render) และ ESLint rule ทางการก็บล็อกไว้ตรง ๆ Effect Event ประกาศได้เฉพาะใน component หรือ hook เดียวกับ effect ที่เรียกมันเท่านั้น — linter บังคับข้อจำกัดนี้ด้วยเช่นกัน
import { useEffect, useEffectEvent, useState } from 'react';import { act, render, cleanup } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';
afterEach(cleanup);
function Timer({ onTick }: { onTick: () => void }) { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1);
const onTickEvent = useEffectEvent(() => { setCount((c) => c + increment); onTick(); });
useEffect(() => { const id = setInterval(onTickEvent, 1000); return () => clearInterval(id); }, []);
return ( <div> <output data-testid="count">{count}</output> <button onClick={() => setIncrement((i) => i + 1)}>bump increment</button> </div> );}
test('useEffectEvent reads the latest increment without re-running the effect (no interval reset)', () => { vi.useFakeTimers(); const onTick = vi.fn(); const { getByTestId, getByRole } = render(<Timer onTick={onTick} />);
act(() => { vi.advanceTimersByTime(1000); // tick with increment=1 }); expect(getByTestId('count').textContent).toBe('1');
act(() => { getByRole('button').click(); // increment becomes 2 — if the effect had }); // re-run, the interval would reset here.
act(() => { vi.advanceTimersByTime(1000); // tick with increment=2, same interval }); expect(getByTestId('count').textContent).toBe('3'); // 1 + 2, not reset to 0 expect(onTick).toHaveBeenCalledTimes(2); // interval never got torn down/rebuilt
vi.useRealTimers();});✓ useEffectEvent reads the latest increment without re-running the effect (no interval reset)
Test Files 1 passed (1) Tests 1 passed (1)Output จริงจาก ESLint ([email protected] ตัวที่สืบต่อจากสาย v6 ที่ compiler rules ถูกรวมเข้ามา) สำหรับ version ที่ใส่ Effect Event ใน deps array จริง ๆ:
warning Functions returned from `useEffectEvent` must not be included in the dependency array.Remove `onTickEvent` from the list react-hooks/exhaustive-depsuseEffectEvent ออกเป็น stable feature ใน React 19.2 (ตุลาคม 2025) — ประกาศ React 19.2 ระบุไว้เคียงข้าง <Activity> และ React Performance Tracks ถ้ายังใช้เวอร์ชันเก่ากว่านี้ วิธี mirror ค่าด้วย ref ยังเป็น fallback ที่ถูกต้อง
Fetch ใน effect: race condition และ AbortController
หัวข้อที่มีชื่อว่า “Fetch ใน effect: race condition และ AbortController”การ fetch ตรงในตัว effect มีบั๊กคลาสสิก: ถ้า userId เปลี่ยนเร็ว (เช่นคลิกไล่ในลิสต์) request ที่เก่ากว่าและช้ากว่าอาจ resolve หลัง request ที่ใหม่กว่าและเร็วกว่า — แล้วเขียนทับ state สดด้วยข้อมูลเก่า การ return AbortController.abort() จาก cleanup แก้ปัญหานี้: promise ของ request เก่าจะ reject แทนที่จะ resolve เข้า state
import { useEffect, useState } from 'react';
declare function fetchUser(userId: string, signal: AbortSignal): Promise<string>;
function Profile({ userId }: { userId: string }) { const [name, setName] = useState('loading'); useEffect(() => { const controller = new AbortController(); fetchUser(userId, controller.signal) .then((result: string) => setName(result)) .catch((err: Error) => { if (err.name !== 'AbortError') throw err; // ignore our own abort }); return () => controller.abort(); // cancel the in-flight request }, [userId]); return <output>{name}</output>;}
export default Profile;พิสูจน์ด้วย fake request “ช้า” ที่เริ่มก่อนแต่ resolve หลัง:
import { useEffect, useState } from 'react';import { act, render, cleanup, screen, waitFor } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';
afterEach(cleanup);
// Simulates a slow-then-fast response: the FIRST request resolves LAST.function fakeFetchUser(id: string, signal: AbortSignal): Promise<string> { const delay = id === 'slow' ? 50 : 10; return new Promise((resolve, reject) => { const t = setTimeout(() => resolve(`user:${id}`), delay); signal.addEventListener('abort', () => { clearTimeout(t); reject(new DOMException('Aborted', 'AbortError')); }); });}
function Profile({ userId }: { userId: string }) { const [name, setName] = useState('loading'); useEffect(() => { const controller = new AbortController(); fakeFetchUser(userId, controller.signal) .then((result) => setName(result)) .catch((err) => { if (err.name !== 'AbortError') throw err; }); return () => controller.abort(); }, [userId]); return <output data-testid="name">{name}</output>;}
test('AbortController prevents the stale (slow) response from overwriting the fresh one', async () => { const { rerender } = render(<Profile userId="slow" />); // Switch to a fast request before the slow one resolves — without abort, // the slow response would land last and overwrite the fresh state. await act(async () => { rerender(<Profile userId="fast" />); }); await waitFor(() => expect(screen.getByTestId('name').textContent).toBe('user:fast')); // Give the (aborted) slow request's original timer a chance to fire too. await new Promise((r) => setTimeout(r, 60)); expect(screen.getByTestId('name').textContent).toBe('user:fast');});✓ AbortController prevents the stale (slow) response from overwriting the fresh one
Test Files 1 passed (1) Tests 1 passed (1)ถ้าไม่มี AbortController test นี้จะ fail: .then ของ request ช้ายังยิงหลังจาก request เร็ว 40ms และทำให้ name กลับไปเป็น user:slow
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”effect ไม่ใช่ lifecycle hook ที่ผูกกับการเกิด-ตายของ component แต่เป็น synchronization loop ที่ผูกกับ reactive value ตัวเดียว (หรือชุดเดียว) มองว่า React รัน “cleanup ค่าเก่า, setup ค่าใหม่” ใหม่ทุกครั้งที่ dependency ใดเปลี่ยน โดย mount กับ unmount เป็นแค่ setup แรกและ cleanup สุดท้ายของ loop เดียวกันนั้นเอง useEffectEvent มีขึ้นเพราะ “อ่านค่าล่าสุด” กับ “re-sync ใหม่” เป็นความต้องการคนละอย่างที่เคยถูกยัดรวมอยู่ใน dependency array เดียวกัน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
| Derive state ตอน render แทน effect | render น้อยลง ไม่มี state ซ้ำซ้อน | ไม่มี — ควรเลือกทางนี้เสมอถ้าทำได้ |
useEffectEvent สำหรับการอ่านที่ไม่ reactive | ไม่มี stale closure ไม่ต้อง re-sync โดยไม่จำเป็น | มีตั้งแต่ React 19.2 เท่านั้น; เรียกนอก effect/component ของตัวเองไม่ได้ |
AbortController ใน cleanup | ไม่มี race condition ที่เขียนทับ state สดด้วยข้อมูลเก่า | ทุก fetch-in-effect ต้องมี boilerplate นี้ หรือใช้ data-fetching library ที่ทำให้อยู่แล้ว |
| ไม่ใส่ dependency array เลย | เขียนง่าย | รันหลังทุก render — มักเป็นเครื่องมือที่ผิด |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “ใส่ value เข้า dependency array เพื่อให้ linter เงียบก็พอ” บางครั้งถูก แต่บ่อยครั้งเป็นสัญญาณว่า effect กำลังทำสองเรื่องที่ไม่เกี่ยวกัน (reconnect กับ notify) ที่ควรแยกออกจากกัน หรือ value นั้นควรอ่านผ่าน
useEffectEventแทน - “effect รันตามลำดับที่เขียนไว้ใน component เดียว” จริงภายใน component เดียว แต่ข้าม tree ลำดับ mount คือ child-ก่อน-parent และลำดับ unmount คือ parent-ก่อน-child — พิสูจน์แล้วด้านบน
- “deps ของ
useEffectEventเติมอัตโนมัติเหมือน function ปกติ” ตรงข้ามเลย: การใส่ตัว Effect Event เองเข้า array คือ ESLint error เพราะจุดประสงค์ทั้งหมดของมันคือการไม่ reactive - “การ abort fetch เป็น cleanup ที่มีก็ได้ไม่มีก็ได้” ถ้าไม่มี component ที่เปลี่ยน data dependency เร็ว (search-as-you-type, สลับ tab) จะโชว์ data ของ selection ก่อนหน้า เป็นพัก ๆ — เป็นบั๊กจริงที่ user เห็น ไม่ใช่แค่ network เปลือง
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ component ที่ generate มาใช้ useEffect ให้เช็กสิ่งเหล่านี้ก่อนเชื่อมัน:
- dependency array มีทุกอย่างที่ effect อ่านครบไหม หรือมี value ถูก “ตัดออก” เพื่อหยุดไม่ให้ effect รันใหม่ — แทนที่จะใช้
useEffectEventหรือ restructure? - ถ้า effect อ่าน value ที่เปลี่ยนบ่อยแต่ไม่ควรทำให้ re-sync (เช่น callback หรือ theme) มันถูกห่อด้วย
useEffectEventหรือถูกตัดออกจาก deps เงียบ ๆ? - effect ที่
fetch/subscribe return cleanup ที่ abort หรือ unsubscribe จริงไหม — หรือ leak ตอน unmount และทุกครั้งที่ dependency เปลี่ยน? - ถ้า value ที่ effect ขึ้นกับเปลี่ยนเร็วได้ (id จากลิสต์, search query) มี
AbortControllercancel ไหม หรือ response เก่าเขียนทับ state สดได้? - effect กำลังทำสิ่งที่ควรอยู่ใน event handler (analytics, POST) แทนการตอบสนอง state flag หรือเปล่า?
🐛 หา bug ในโค้ดนี้
function Timer() {const [count, setCount] = useState(0);const [increment, setIncrement] = useState(1);
useEffect(() => { const id = setInterval(() => { setCount((c) => c + increment); // reads increment from the closure }, 1000); return () => clearInterval(id);}, []); // empty deps — the interval is only ever set up once
return ( <div> <output>{count}</output> <button onClick={() => setIncrement((i) => i + 1)}>bump increment</button> </div>);}เฉลย
interval callback closure ค้าง increment จาก render ตอนที่ effect รัน — ด้วย dependency array ว่าง effect นี้รันแค่ครั้งเดียวตอน mount ดังนั้น callback ยังอ้างอิง increment เริ่มต้น (1) ตลอดไป การกดปุ่ม “bump increment” เปลี่ยน state increment และ re-render component แต่ setInterval callback ที่รันอยู่แล้วถูกสร้างแค่ครั้งเดียวและไม่ถูกสร้างใหม่เลย ทุก tick จึงยังบวก 1 เสมอ ไม่ว่าจะกดปุ่มกี่ครั้ง พิสูจน์แล้วในโพรบ: version ที่แก้แล้ว (แยก tick logic ออกมาเป็น useEffectEvent ตามที่แสดงไว้ก่อนหน้าในบทนี้) ได้ count === 3 หลัง tick หนึ่งครั้งที่ increment=1 และอีกครั้งที่ increment=2 — version ที่มีบั๊กนี้จะยังโชว์ 2 (0 + 1 + 1) แทน ทางแก้คือ useEffectEvent ไม่ใช่การใส่ increment เข้า deps array (แบบนั้นจะ reconnect — สร้าง interval ใหม่ — ทุกครั้งที่มันเปลี่ยน ซึ่งเป็นบั๊กของตัวมันเอง: นับถอยหลังจะรีสตาร์ทแทนที่จะเดินสม่ำเสมอ)