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

Lifting State & Composition

เมื่อ 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 สองตัวไม่มีทางไม่ตรงกันเพราะมีค่าเดียว

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 ทันที แต่มีทางแก้ที่ง่ายกว่าให้ลองก่อน

ก่อนจะไปหา 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
Drilling ร้อย props ผ่าน; composition ส่งเนื้อหาที่ render แล้วเข้าไป

คุณส่ง 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 ของผู้เรียกนั้นไม่ได้เปลี่ยน

src/components/ChildrenBail.tsx
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>
);
}
src/__tests__/children-bail.test.tsx
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 ฟรีไปด้วย

form input เป็น controlled เมื่อ value ของมันมาจาก React state (value={state} บวก onChange) — React เป็นเจ้าของ value เต็มตัว มันเป็น uncontrolled เมื่อ DOM เป็นเจ้าของ (defaultValue, อ่านผ่าน ref เมื่อต้องการ) ทั้งสองแบบถูกต้องตามหลัก กฎที่สำคัญจริง ๆ คือ: อย่าสลับไปมาระหว่างสองแบบสำหรับ input เดียวกันตลอดอายุของมัน

พิสูจน์: คำเตือนจริงเมื่อ input สลับจาก uncontrolled เป็น controlled

หัวข้อที่มีชื่อว่า “พิสูจน์: คำเตือนจริงเมื่อ input สลับจาก uncontrolled เป็น controlled”
src/components/ControlledWarning.tsx
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={() => {}} />;
}
src/__tests__/controlled-warning.test.tsx
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 ?? ''}

anti-pattern ที่พบบ่อย: การ mirror prop เข้า state ด้วย useEffect ทั้งที่ค่าคำนวณได้ตอน render เลย เวอร์ชัน effect ทำงานเดิมทุกอย่าง บวก render เพิ่มอีกครั้ง บวกเฟรมที่ state ค้างเก่าอยู่ชั่วขณะ

src/components/DerivedVsEffect.tsx
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>;
}
src/__tests__/derived-vs-effect.test.tsx
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 ของ list ให้ identity ที่ React ใช้ diff — ครอบคลุมไว้ในบท rendering & reconciliation กลไกเดียวกันนี้มีการใช้แบบที่สอง จงใจ ที่นี่: การเปลี่ยน key ของ element บังคับให้ React ทิ้ง instance เก่าแล้ว mount ตัวใหม่เอี่ยม รีเซ็ต local state ทั้งหมดของมัน นี่คือทางแก้ที่ idiomatic สำหรับ “state ภายในของ component ตัวนี้ต้องรีเซ็ตทั้งหมดเมื่อค่าระบุตัวตนบางอย่างเปลี่ยน” — สะอาดกว่าการรีเซ็ต state ทีละชิ้นด้วยมือเอง

src/components/KeyReset.tsx
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} />;
}
src/__tests__/key-reset.test.tsx
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

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 inputvalue ตรงกับ React state เสมอ validate/transform ได้ทุก keystrokeต้องส่ง value ที่ defined เสมอ — ห้ามสลับไปมาที่ undefined
Uncontrolled inputre-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 หรือเปล่าเลย

ก่อนเชื่อ component ที่ generate มาเรื่องการวาง state ให้เช็กสิ่งเหล่านี้:

  • component ถูกนิยามแบบ inlineเป็น JSX ลึกเข้าไปข้างใน component อื่นที่ re-render บ่อย ๆ แทนที่จะถูกส่งเข้ามาเป็น children/prop จากข้างบนไหม? นั่นคือ drilling โดยโครงสร้าง และยังหมายความว่า subtree นั้นไม่มีทางข้าม re-render แบบที่ composed children ทำได้
  • 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 ใหม่จริง ๆ แทนที่จะพกของเก่ามาด้วย

เมื่อ sibling component สองตัวต้องการ state เดียวกัน มันควรอยู่ที่ไหน?
prop drilling คืออะไร?
ทางแก้ composition สำหรับ prop drilling คืออะไร?
ใน one-way data flow ข้อมูลไหลอย่างไร?
นอกจากแก้ prop drilling แล้ว การส่ง children เป็น prop ยังทำอะไรอีก?
คำเตือนจริงของ React เมื่อ value ของ <input> เปลี่ยนจาก undefined เป็น string ที่ defined ข้าม render คืออะไร?
เทียบกับการคำนวณ fullName ตรง ๆ ตอน render (ไม่มี effect ไม่มี state เพิ่ม) เวอร์ชันนี้เสียอะไรไปบ้าง?
function Comp({ firstName, lastName, onRender }) {
  const [fullName, setFullName] = useState(`${firstName} ${lastName}`);
  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);
  onRender();
  return <span>{fullName}</span>;
}