Lifting State & Composition
Lifting state ขึ้น
หัวข้อที่มีชื่อว่า “Lifting state ขึ้น”เมื่อ component สองตัวต้องแชร์ state เดียวกัน state นั้นอยู่ในตัวใดตัวหนึ่งไม่ได้ — มันต้องย้าย ขึ้นไป ที่ common ancestor ที่ใกล้ที่สุด แล้วส่งลงมาเป็น props นี่คือ lifting state up และเป็นคำตอบเริ่มต้นของ “component สองตัวนี้ต้อง sync กัน”
// The parent owns the state; both children read/update it through props.function TemperatureApp() { const [celsius, setCelsius] = useState(0); return ( <> <CelsiusInput value={celsius} onChange={setCelsius} /> <FahrenheitLabel celsius={celsius} /> </> );}state มี แหล่งความจริงเดียว (parent) และข้อมูลไหลทางเดียว: ลงมาเป็น props กลับขึ้นไปเป็น callback input สองตัวไม่มีทางไม่ตรงกันเพราะมีค่าเดียว
ปัญหา prop drilling
หัวข้อที่มีชื่อว่า “ปัญหา prop drilling”Lifting ทำงานได้ดีจนกว่า ancestor จะอยู่ไกลจาก consumer มาก ตอนนั้น props ต้องร้อยผ่านทุก component ตรงกลางที่ไม่ได้สนใจมันเลย:
// theme is only used by Button, but every layer must pass it through.<App theme={theme}> <Page theme={theme}> <Sidebar theme={theme}> <Button theme={theme} /> {/* finally used here */} </Sidebar> </Page></App>ทุก component ตรงกลางได้ prop ที่มันแค่ส่งต่อ นี่คือ prop drilling — รกและปวดหัวตอน refactor สัญชาตญาณคือกระโดดไปหา Context ทันที แต่มีทางแก้ที่ง่ายกว่าให้ลองก่อน
Composition: ส่ง JSX เป็น prop
หัวข้อที่มีชื่อว่า “Composition: ส่ง JSX เป็น prop”ก่อนจะไปหา Context ให้ลอง composition ก่อน แทนที่จะ drill ค่าผ่าน component ให้ส่ง JSX ที่ถูก render แล้วเป็น children (หรือ prop ที่ตั้งชื่อไว้) เพื่อให้ component ตรงกลางไม่เห็นค่านั้นเลย
// Sidebar doesn't know about `theme` — it just renders whatever children it's given.function Sidebar({ children }) { return <aside className="sidebar">{children}</aside>;}
// The Button is created where `theme` is in scope, then handed to Sidebar as content.<Page> <Sidebar> <Button theme={theme} /> {/* theme is bound here, not drilled */} </Sidebar></Page>flowchart TB
subgraph drill["Prop drilling"]
a1["App theme"] --> p1["Page theme"] --> s1["Sidebar theme"] --> b1["Button uses theme"]
end
subgraph comp["Composition"]
a2["App: renders Button here"] --> s2["Sidebar: renders children"]
a2 -.passes JSX.-> s2
end คุณส่ง JSX ผ่าน prop ที่ตั้งชื่อไว้ก็ได้เหมือนกัน (<Layout sidebar={<Nav />} main={<Feed />} />) component ตรงกลางกลายเป็น container ทั่วไป (“render อะไรก็ตามที่ได้รับมา”) ซึ่ง reusable กว่า และ ตัด drilling ออกไปด้วย
พิสูจน์: children เป็น prop ยังกัน re-render ได้ด้วย ไม่ใช่แค่แก้ drilling
หัวข้อที่มีชื่อว่า “พิสูจน์: children เป็น prop ยังกัน re-render ได้ด้วย ไม่ใช่แค่แก้ drilling”Composition มีประโยชน์ที่สองที่ไม่ค่อยชัดเจน: มันเป็น การ optimize performance โดยไม่ต้องมี memo เลย เมื่อ component ที่ถือ state ที่เปลี่ยนเร็ว render {children} React ไม่จำเป็นต้อง re-render subtree ของ children นั้นทุกครั้งที่ state เปลี่ยน — เพราะ element children ถูกสร้างโดยผู้ที่เรียก component นี้ และ JSX ของผู้เรียกนั้นไม่ได้เปลี่ยน
import { useState, type ReactNode } from 'react';
function ExpensiveChild({ onRender }: { onRender: () => void }) { onRender(); return <span>child</span>;}
// The ticking state lives INSIDE FastTimer. `children` is whatever its// caller passed in — FastTimer never re-creates it.function FastTimer({ children }: { children: ReactNode }) { const [tick, setTick] = useState(0); return ( <div> <span data-testid="tick">{tick}</span> <button onClick={() => setTick((t) => t + 1)}>tick</button> {children} </div> );}
export function ComposedApp({ onChildRender }: { onChildRender: () => void }) { return ( <FastTimer> <ExpensiveChild onRender={onChildRender} /> </FastTimer> );}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { ComposedApp } from '../components/ChildrenBail';
afterEach(cleanup);
test('children passed as props do not re-render when the parent that renders them ticks', () => { let renders = 0; render(<ComposedApp onChildRender={() => renders++} />); const baseline = renders;
act(() => { screen.getByRole('button').click(); }); act(() => { screen.getByRole('button').click(); });
// FastTimer re-rendered twice (tick went 0 -> 1 -> 2), but ExpensiveChild — // passed in as `children` — rendered zero additional times. expect(renders - baseline).toBe(0); expect(screen.getByTestId('tick').textContent).toBe('2');});✓ children passed as props do not re-render when the parent that renders them ticks
Test Files 1 passed (1) Tests 1 passed (1)FastTimer re-render ทุกครั้งที่คลิก (state tick ของมันเองเปลี่ยน) แต่ ExpensiveChild ไม่ re-render แม้แต่ครั้งเดียว — ไม่มี memo เกี่ยวข้องเลย ComposedApp สร้าง element <ExpensiveChild /> แค่ครั้งเดียว แล้วส่งให้ FastTimer เป็น children; การ re-render ของ FastTimer เองไม่แตะ element นั้นเลย React จึงข้าม re-render subtree ที่มันแทนได้ นี่คือเหตุผลที่ “ยก state ลงไปไว้ที่ component เล็กที่สุดที่ต้องการมัน แล้วส่งทุกอย่างที่เหลือผ่านเป็น children” เป็นทั้งทางแก้ composition สำหรับ prop drilling และ ได้ performance ฟรีไปด้วย
Controlled เทียบกับ uncontrolled input
หัวข้อที่มีชื่อว่า “Controlled เทียบกับ uncontrolled input”form input เป็น controlled เมื่อ value ของมันมาจาก React state (value={state} บวก onChange) — React เป็นเจ้าของ value เต็มตัว มันเป็น uncontrolled เมื่อ DOM เป็นเจ้าของ (defaultValue, อ่านผ่าน ref เมื่อต้องการ) ทั้งสองแบบถูกต้องตามหลัก กฎที่สำคัญจริง ๆ คือ: อย่าสลับไปมาระหว่างสองแบบสำหรับ input เดียวกันตลอดอายุของมัน
พิสูจน์: คำเตือนจริงเมื่อ input สลับจาก uncontrolled เป็น controlled
หัวข้อที่มีชื่อว่า “พิสูจน์: คำเตือนจริงเมื่อ input สลับจาก uncontrolled เป็น controlled”export function FlippingInput({ startControlled }: { startControlled: boolean }) { // ❌ `value` starts as `undefined` (uncontrolled), then becomes a string // (controlled) once `startControlled` flips — the exact trap. const value = startControlled ? 'hello' : undefined; return <input value={value} onChange={() => {}} />;}import { render, act, cleanup } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';import { FlippingInput } from '../components/ControlledWarning';
afterEach(cleanup);
test('flipping value from undefined to a string logs the real React warning', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); const { rerender } = render(<FlippingInput startControlled={false} />);
act(() => { rerender(<FlippingInput startControlled={true} />); });
const messages = spy.mock.calls.map((c) => String(c[0])); expect(messages.some((m) => m.includes('changing an uncontrolled input to be controlled'))).toBe(true);
spy.mockRestore();});✓ flipping value from undefined to a string logs the real React warning
Test Files 1 passed (1) Tests 1 passed (1)ข้อความเป๊ะ ๆ: "A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://react.dev/link/controlled-components" สาเหตุปกติคือ state ที่ initialize จากอะไรบางอย่างที่เป็น null/undefined ได้ตอนแรก (API response, prop ที่ optional) — เอกสารอ้างอิง <input> แนะนำทางแก้ง่าย ๆ: บังคับให้เป็น string ตั้งแต่แรก value={someValue ?? ''}
Derived state แทน effect ที่ sync
หัวข้อที่มีชื่อว่า “Derived state แทน effect ที่ sync”anti-pattern ที่พบบ่อย: การ mirror prop เข้า state ด้วย useEffect ทั้งที่ค่าคำนวณได้ตอน render เลย เวอร์ชัน effect ทำงานเดิมทุกอย่าง บวก render เพิ่มอีกครั้ง บวกเฟรมที่ state ค้างเก่าอยู่ชั่วขณะ
import { useState, useEffect } from 'react';
// ❌ Anti-pattern: mirrors props into state via an effect.export function SyncedEffectFullName({ firstName, lastName, onRender,}: { firstName: string; lastName: string; onRender: () => void;}) { const [fullName, setFullName] = useState(`${firstName} ${lastName}`); useEffect(() => { setFullName(`${firstName} ${lastName}`); }, [firstName, lastName]); onRender(); return <span data-testid="name">{fullName}</span>;}
// ✅ Derived during render — no effect, no extra render, never stale.export function DerivedFullName({ firstName, lastName, onRender,}: { firstName: string; lastName: string; onRender: () => void;}) { const fullName = `${firstName} ${lastName}`; onRender(); return <span data-testid="name">{fullName}</span>;}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { SyncedEffectFullName, DerivedFullName } from '../components/DerivedVsEffect';
afterEach(cleanup);
test('the effect-synced version takes one extra render to reach the same result', () => { let renders = 0; const r = render(<SyncedEffectFullName firstName="Ada" lastName="L" onRender={() => renders++} />); act(() => { r.rerender(<SyncedEffectFullName firstName="Grace" lastName="H" onRender={() => renders++} />); }); // mount (1) + prop re-render (1) + effect's own setState re-render (1) = 3. expect(renders).toBe(3); expect(screen.getByTestId('name').textContent).toBe('Grace H');});
test('the derived version reaches the same result with no extra render', () => { let renders = 0; const r = render(<DerivedFullName firstName="Ada" lastName="L" onRender={() => renders++} />); act(() => { r.rerender(<DerivedFullName firstName="Grace" lastName="H" onRender={() => renders++} />); }); // mount (1) + prop re-render (1) = 2 — no effect, nothing to re-sync. expect(renders).toBe(2); expect(screen.getByTestId('name').textContent).toBe('Grace H');});✓ the effect-synced version takes one extra render to reach the same result✓ the derived version reaches the same result with no extra render
Test Files 1 passed (1) Tests 2 passed (2)ทั้งสองแบบลงเอยที่ “Grace H” เหมือนกัน — แต่เวอร์ชัน effect ใช้ 3 render แทนที่จะเป็น 2 และหนึ่งใน render เหล่านั้น DOM ค้าง value เก่า ที่ derive มา ("Ada L") ไว้ชั่วขณะก่อนที่ effect จะแก้ให้ เมื่อไรก็ตามที่ค่าคำนวณได้ตรงจาก props/state ที่มีอยู่แล้ว ให้คำนวณตอน render ใช้ effect เฉพาะเพื่อ sync กับสิ่งที่อยู่นอก React จริง ๆ (DOM, subscription, network request)
key เป็น reset switch
หัวข้อที่มีชื่อว่า “key เป็น reset switch”key ของ list ให้ identity ที่ React ใช้ diff — ครอบคลุมไว้ในบท rendering & reconciliation กลไกเดียวกันนี้มีการใช้แบบที่สอง จงใจ ที่นี่: การเปลี่ยน key ของ element บังคับให้ React ทิ้ง instance เก่าแล้ว mount ตัวใหม่เอี่ยม รีเซ็ต local state ทั้งหมดของมัน นี่คือทางแก้ที่ idiomatic สำหรับ “state ภายในของ component ตัวนี้ต้องรีเซ็ตทั้งหมดเมื่อค่าระบุตัวตนบางอย่างเปลี่ยน” — สะอาดกว่าการรีเซ็ต state ทีละชิ้นด้วยมือเอง
import { useState } from 'react';
function Field({ onMount }: { onMount: () => void }) { const [text, setText] = useState(''); useState(() => { onMount(); return null; }); return <input data-testid="field" value={text} onChange={(e) => setText(e.target.value)} />;}
// ❌ No key — the same Field instance persists across a userId change,// keeping whatever the user had typed for the PREVIOUS user.export function WithoutKey({ onMount }: { userId: string; onMount: () => void }) { return <Field onMount={onMount} />;}
// ✅ Keyed by userId — a new user means a fresh Field instance, reset state.export function WithKey({ userId, onMount }: { userId: string; onMount: () => void }) { return <Field key={userId} onMount={onMount} />;}import { render, screen, fireEvent, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { WithoutKey, WithKey } from '../components/KeyReset';
afterEach(cleanup);
test('without a key, typed text survives a prop change on the same instance', () => { let mounts = 0; const r = render(<WithoutKey userId="a" onMount={() => mounts++} />); fireEvent.change(screen.getByTestId('field'), { target: { value: 'typed' } });
r.rerender(<WithoutKey userId="b" onMount={() => mounts++} />);
expect(mounts).toBe(1); // never remounted expect((screen.getByTestId('field') as HTMLInputElement).value).toBe('typed');});
test('a key change remounts the component and resets its state', () => { let mounts = 0; const r = render(<WithKey userId="a" onMount={() => mounts++} />); fireEvent.change(screen.getByTestId('field'), { target: { value: 'typed' } });
r.rerender(<WithKey userId="b" onMount={() => mounts++} />);
expect(mounts).toBe(2); // remounted once expect((screen.getByTestId('field') as HTMLInputElement).value).toBe(''); // reset});✓ without a key, typed text survives a prop change on the same instance✓ a key change remounts the component and resets its state
Test Files 1 passed (1) Tests 2 passed (2)นี่คนละเรื่องกับ key ของ list: ตรงนั้น key มีไว้เพื่อจับคู่element ข้าม render เพื่อไม่ให้ React สับสน item หนึ่งกับอีกอัน ตรงนี้ key={userId} เป็นสัญญาณจงใจว่า ค่าตัวนี้เฉพาะ คือตัวกำหนด identity ของ component — เปลี่ยนมัน แล้วคุณต้องการเริ่มใหม่ ไม่ใช่แค่ update
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”Composition กับ lifting เป็นปลายสองด้านของคานเดียวกัน: lift state ขึ้นเมื่อ sibling ต้องเห็นค่าเดียวกัน; ส่ง JSX ที่ render แล้วลงไป (เป็น children/props) เมื่อ component ตรงกลางไม่จำเป็นต้องรู้ค่านั้นเลย ทั้งสองไม่ได้เกี่ยวกับว่าข้อมูลเก็บอยู่ที่ไหน — lifting เกี่ยวกับความเป็นเจ้าของ composition เกี่ยวกับว่าใครเห็นค่าได้เทียบกับใครแค่ส่งต่อช่องสำหรับเนื้อหาที่ถูกสร้างที่อื่น component ที่ render แค่ children มองจากมุมการไหลของข้อมูลแล้วมันล่องหน — และมองจากมุมการ render แล้ว การ re-render ของมันถูกเพราะช่องล่องหนนั้นไม่เปลี่ยนแค่เพราะ state ของ wrapper เปลี่ยน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
| Lift state ขึ้น | แหล่งความจริงเดียว sync กันแน่นอน | common ancestor รับผิดชอบเยอะขึ้น |
Composition (children/JSX prop) | แก้ prop drilling และ ข้าม re-render ของ subtree ที่ส่งเข้ามา | component ตรงกลางต้องออกแบบให้ทั่วไปตั้งแต่แรก |
| Controlled input | value ตรงกับ React state เสมอ validate/transform ได้ทุก keystroke | ต้องส่ง value ที่ defined เสมอ — ห้ามสลับไปมาที่ undefined |
| Uncontrolled input | re-render น้อยกว่า ง่ายกว่าสำหรับฟอร์มที่ “แค่อ่านตอน submit” | validate สด หรือควบคุม value แบบ programmatic ยากกว่า |
| Derived state (คำนวณตอน render) | ไม่ render เพิ่ม ไม่ค้างเก่า โค้ดน้อยกว่า | ใช้ได้เฉพาะเมื่อค่าเป็น pure function ของ props/state ที่มีอยู่แล้ว |
key เป็น reset switch | บรรทัดเดียวรีเซ็ต state ภายในทั้งหมดของ component | ต้อง document เจตนา (reset เทียบ list identity) — ทั้งคู่หน้าตาเหมือนกันใน JSX |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Composition ช่วยแค่แก้ prop drilling” มันยังข้าม re-render ของ subtree ที่ส่งเข้ามาเมื่อ state ของ wrapper เองเปลี่ยนด้วย — พิสูจน์แล้วข้างบน โดยไม่มี
memoเลย - “input เริ่มเป็น uncontrolled แล้วกลายเป็น controlled ได้ตอนข้อมูลโหลดเสร็จ” นั่นคือกับดักที่ React เตือนพอดี — พิสูจน์แล้วข้างบนด้วยข้อความเป๊ะ ๆ initialize state เป็นค่าที่ defined (
''ไม่ใช่undefined) ตั้งแต่แรกแทน - “การ mirror prop เข้า state ด้วย
useEffectแค่ efficient น้อยกว่านิดหน่อย” มันไม่ใช่แค่ efficient น้อยกว่า — พิสูจน์แล้วข้างบนว่ามันใช้ render เพิ่มอีกครั้งเพื่อไปถึงผลลัพธ์เดิม และ render ค่าเก่าค้างไว้ชั่วขณะระหว่างนั้น - “
keyมีไว้สำหรับ list เท่านั้น”keyบน element เดี่ยว ๆ ก็เป็นวิธี idiomatic ที่ถูกต้องในการบังคับ remount-and-reset ทั้งหมด — พิสูจน์แล้วข้างบน — ไม่เกี่ยวกับว่า element นั้นอยู่ใน list หรือเปล่าเลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ component ที่ generate มาเรื่องการวาง state ให้เช็กสิ่งเหล่านี้:
- component ถูกนิยามแบบ inlineเป็น JSX ลึกเข้าไปข้างใน component อื่นที่ re-render บ่อย ๆ แทนที่จะถูกส่งเข้ามาเป็น
children/prop จากข้างบนไหม? นั่นคือ drilling โดยโครงสร้าง และยังหมายความว่า subtree นั้นไม่มีทางข้าม re-render แบบที่ composedchildrenทำได้ valueของ<input>มาจากอะไรบางอย่างที่เป็นnull/undefinedได้ก่อนข้อมูลโหลดเสร็จไหม (API response, field ที่ optional)? นั่นคือการสลับ controlled/uncontrolled ที่พิสูจน์แล้วข้างบน — ตั้งค่า default ให้เป็นค่าที่ defined ทันที- มี
useEffectที่งานเดียวคือsetSomething(propOrOtherState)โดยไม่มี side effect อื่นไหม? แทบจะ derive ได้ตอน render เสมอ — พิสูจน์แล้วข้างบนว่ามันเสีย render เพิ่มและเฟรมที่ค้างเก่า key={index}ถูกใช้กับ child ที่มี form หรือ state ทั้งที่เจตนาจริง ๆ คือ “reset เมื่อ record ที่อ้างอิงเปลี่ยน” แทนที่จะใช้ id ที่ stable ของ record เองไหม? index ไม่ได้ระบุอะไรที่มีความหมายจริงและอาจ reset (หรือไม่ reset) state ผิดพลาดตอน item ถูกเพิ่ม/ลบ/สลับลำดับ- component ไปหา Context (หรือ prop drilling) สำหรับค่าที่ component แค่หนึ่งหรือสองตัวห่างลงไปสองสามชั้นต้องการเท่านั้นไหม ทั้งที่การส่ง JSX ที่ render แล้วเป็น
childrenจะตัดความจำเป็นที่ชั้นตรงกลางต้องรู้จักมันเลยได้?
🐛 หา bug ในโค้ดนี้
function UserProfilePage({ userId }) {const user = useUser(userId);return <ProfileForm initialName={user.name} />;}
function ProfileForm({ initialName }) {const [name, setName] = useState(initialName);return <input value={name} onChange={(e) => setName(e.target.value)} />;}เฉลย
เมื่อ userId เปลี่ยน (นำทางจาก profile ของ user หนึ่งไปอีกคน) ProfileForm เป็น instance ของ component เดียวกัน — React แค่ re-render มันด้วย prop initialName ใหม่ ไม่ได้ remount มัน แต่ useState(initialName) ใช้ argument นั้นแค่ render แรกเท่านั้น ทุก render ถัดไปจะเก็บสิ่งที่ user ก่อนหน้า แก้ไว้ใน name ต่อไป แสดงข้อความเก่าของ user A แบบเงียบ ๆ ระหว่างที่กำลังแก้ profile ของ user B ทางแก้คือ pattern key ที่พิสูจน์แล้วข้างบนพอดี: <ProfileForm key={userId} initialName={user.name} /> — userId ใหม่จะบังคับให้เกิด ProfileForm instance ใหม่เอี่ยม ดังนั้น useState(initialName) จะ re-initialize จากข้อมูลของ user ใหม่จริง ๆ แทนที่จะพกของเก่ามาด้วย