Kodokon kodokon.com

Экспертные паттерны: составные компоненты, инверсия управления

Проектируй гибкие API компонентов с помощью составных компонентов, state reducer и верного выбора между render props и хуками.

10 мин · 3 вопросов

Открыть этот урок в Kodokon

Компонент <Tabs items={...} labels={...} icons={...} /> рано или поздно оказывается погребён под пропами конфигурации. Паттерн составных компонентов (compound components) переворачивает подход: несколько компонентов сотрудничают и делят неявное состояние через контекст, а пользователь сохраняет контроль над композицией JSX (порядок, вложенность, вставленные между ними элементы). Это модель <select> и <option> в HTML и та же, которую используют библиотеки вроде Radix или Headless UI.

JSX
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: без него каждый рендер родителя создавал бы новый объект и заставлял бы рендериться всех потребителей.

JSX
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. Вместо того чтобы добавлять булев проп на каждую новую потребность, хук выставляет наружу свои переходы состояния: вызывающая сторона предоставляет редьюсер, который получает каждое действие и может пропустить его, изменить или отменить. Библиотека сохраняет поведение по умолчанию; тонкий контроль принадлежит потребителю.

JSX
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 в дереве вызывающей стороны.

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();
Render prop - чтобы делегировать рендеринг, хук - для логики.

Проверка знаний

Убедись, что запомнил ключевые моменты этого урока.

  1. Какой механизм позволяет составным компонентам делить состояние без явных пропов?
    • Общая модульная переменная
    • Контекст React, предоставленный родительским компонентом
    • Дублирование состояния в каждом потомке
    • Рефы, переданные через cloneElement
  2. Что именно инвертирует паттерн state reducer?
    • Направление потока данных между родителем и потомком
    • Контроль над переходами состояния, отданный вызывающей стороне
    • Порядок выполнения хуков
    • Ответственность за рендеринг fallback
  3. В каком случае render prop остаётся предпочтительнее хука?
    • Когда нужно переиспользовать логику между компонентами
    • Когда вызывающая сторона должна предоставить JSX для вставки в дерево компонента
    • Когда нужно мемоизировать дорогое вычисление
    • Когда у компонента нет состояния