Проектируй гибкие API компонентов с помощью составных компонентов, state reducer и верного выбора между render props и хуками.
Открыть этот урок в KodokonКомпонент <Tabs items={...} labels={...} icons={...} /> рано или поздно оказывается погребён под пропами конфигурации. Паттерн составных компонентов (compound components) переворачивает подход: несколько компонентов сотрудничают и делят неявное состояние через контекст, а пользователь сохраняет контроль над композицией 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>
);
}Каждый подкомпонент читает контекст и вообще не нуждается в связующих пропах: пользователь свободно компонует, а паттерн гарантирует согласованность. Обрати внимание на значение контекста, обёрнутое в useMemo: без него каждый рендер родителя создавал бы новый объект и заставлял бы рендериться всех потребителей.
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>Второй паттерн - инверсия управления, чья самая утончённая форма это state reducer, популяризированный Кентом Доддсом в Downshift. Вместо того чтобы добавлять булев проп на каждую новую потребность, хук выставляет наружу свои переходы состояния: вызывающая сторона предоставляет редьюсер, который получает каждое действие и может пропустить его, изменить или отменить. Библиотека сохраняет поведение по умолчанию; тонкий контроль принадлежит потребителю.
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 против хуков. Хуки победили в переиспользовании логики: никакой фальшивой иерархии в дереве, никакой пирамиды обёрток, естественная композиция нескольких источников. Но за render prop остаётся законная территория: когда вариативность касается самого рендеринга. Виртуализированный список, который спрашивает у тебя, как рисовать каждую строку, 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();