ออกแบบ API ของคอมโพเนนต์ที่ยืดหยุ่นด้วย compound components, state reducer, และการเลือกที่ถูกต้องระหว่าง render props กับ hooks
เปิดบทเรียนนี้ใน Kodokonคอมโพเนนต์ <Tabs items={...} labels={...} icons={...} /> มักจะจบลงด้วยการถูกฝังอยู่ใต้พร็อพการกำหนดค่ามากมายเสมอ แพตเทิร์น compound components พลิกวิธีคิด: คอมโพเนนต์หลายตัวร่วมมือกันและแบ่งปัน สเตตโดยปริยาย (implicit state) ผ่านคอนเท็กซ์ (context) ในขณะที่ผู้ใช้ยังคงควบคุม การประกอบ JSX (ลำดับ การซ้อน การสอดแทรกเอลิเมนต์) ได้ นี่คือโมเดลของ <select> และ <option> ใน HTML และเป็นโมเดลที่ไลบรารีอย่าง Radix หรือ Headless UI ใช้
const TabsContext = createContext(null);
function Tabs({ defaultTab, children }) {
const [active, setActive] = useState(defaultTab);
const value = useMemo(
() => ({ active, setActive }),
[active]
);
return (
<TabsContext.Provider value={value}>
{children}
</TabsContext.Provider>
);
}แต่ละซับคอมโพเนนต์อ่านคอนเท็กซ์และไม่จำเป็นต้องมี พร็อพเชื่อมต่อ (wiring props) ใด ๆ เลย: ผู้ใช้ประกอบได้อย่างอิสระ และแพตเทิร์นรับประกันความสอดคล้อง สังเกตค่าคอนเท็กซ์ที่ห่อไว้ใน useMemo: หากไม่มีมัน ทุกการเรนเดอร์ของพาเรนต์จะสร้างอ็อบเจกต์ใหม่และบังคับให้ผู้บริโภค (consumer) ทั้งหมด เรนเดอร์ใหม่
function Tab({ id, children }) {
const ctx = useContext(TabsContext);
return (
<button
aria-selected={ctx.active === id}
onClick={() => ctx.setActive(id)}
>
{children}
</button>
);
}
// <Tabs defaultTab="a">
// <Tab id="a">Profile</Tab>
// <Tab id="b">Settings</Tab>
// </Tabs>แพตเทิร์นที่สอง: การกลับด้านการควบคุม (inversion of control) ซึ่งรูปแบบที่ประณีตที่สุดคือ state reducer ที่ Kent C. Dodds ทำให้เป็นที่นิยมใน Downshift แทนที่จะเพิ่มพร็อพแบบบูลีนสำหรับทุกความต้องการใหม่ ฮุกจะเปิดเผย การเปลี่ยนสเตต (state transitions) ของมัน: ผู้เรียก (caller) จัดหา reducer ที่รับแต่ละแอ็กชัน (action) และสามารถปล่อยผ่าน แก้ไข หรือยกเลิกมันได้ ไลบรารียังคงพฤติกรรมเริ่มต้นไว้; การควบคุมแบบละเอียดเป็นของผู้บริโภค
function defaultReducer(state, action) {
if (action.type === 'toggle') {
return { on: !state.on };
}
return state;
}
function useToggle({ reducer = defaultReducer } = {}) {
const [state, dispatch] = useReducer(reducer, {
on: false,
});
const toggle = () => dispatch({ type: 'toggle' });
return { on: state.on, toggle };
}สุดท้าย คือการถกเถียงชั่วนิรันดร์ระหว่าง render props กับ hooks ฮุกชนะไปแล้วในเรื่อง การแบ่งปันตรรกะ (sharing logic): ไม่มีลำดับชั้นปลอมในต้นไม้ ไม่มีพีระมิดของตัวห่อ (wrapper) การประกอบแหล่งที่มาหลายแหล่งเป็นไปตามธรรมชาติ แต่ render prop ยังคงมีดินแดนที่ชอบธรรมของมัน: เมื่อความแตกต่างเกี่ยวข้องกับ ตัวการเรนเดอร์เอง ลิสต์แบบเวอร์ชวลไลซ์ (virtualized list) ที่ถามคุณว่าจะวาดแต่ละแถวอย่างไร คอมโพเนนต์แบบ headless ที่มอบหมาย JSX ทั้งหมดของมัน: ในกรณีเหล่านี้ ฟังก์ชันเรนเดอร์ที่ส่งเป็นพร็อพยังคงเป็นเครื่องมือที่ตรงที่สุด เพราะฮุกไม่สามารถตัดสินใจได้ว่าจะแทรก JSX ที่ไหน ในต้นไม้ของผู้เรียก
function MouseTracker({ render }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
const onMove = (e) =>
setPos({ x: e.clientX, y: e.clientY });
return (
<div onMouseMove={onMove}>{render(pos)}</div>
);
}
// For the logic alone, a hook is enough:
// const pos = useMousePosition();