open atlas
↑ К треку
Паттерны React RXP · 03 · 01

Render-props

Render-проп — это проп, значение которого возвращает JSX: он делит поведение, а разметкой владеет вызывающий. Для чистой логики его вытеснили кастомные хуки, но он всё ещё уместен, когда нужно что-то внедрить во время рендера.

RXP Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

У тебя есть Toggle, управляющий булевым значением вкл/выкл. Один экран хочет отрисовать его как переключатель, другой — как чекбокс, третий — как две кнопки. Поведение — состояние, обработчик переключения — идентично; различается только разметка. Если запечь разметку в Toggle, каждый новый вид форкает компонент. Если скопировать логику состояния в каждого потребителя, ты продублировал поведение.

Render-проп — классический выход из этой ловушки: пусть Toggle владеет поведением и отдаёт его обратно вызывающему, который решает, что рисовать. Это паттерн, научивший React инверсии управления, — и хотя хуки забрали большинство его прежних задач, есть срез работы, где render-проп всё ещё чище.

Цель

После этого урока ты можешь определить render-проп как проп, значение которого — функция, возвращающая JSX; объяснить, как состояние и обработчики текут из владеющего компонента в разметку вызывающего; распознать, что кастомные хуки вытеснили render-props для переиспользования чистой логики и почему; и назвать случаи, где render-проп всё ещё правильный выбор — инъекция во время рендера, как рендерер строки виртуализированного списка или фетчер, рисующий ветки loading/error/data, — плюс его режим отказа, ад вложенных render-проп-колбэков.

1

Render-проп — это проп, значение которого — функция, возвращающая JSX; компонент вызывает её, чтобы отрисовать. Вместо возврата фиксированной разметки владеющий компонент вызывает функцию и передаёт своё внутреннее состояние и обработчики как аргументы. Вызывающий получает их и решает, что рисовать. children — это просто render-проп со специальным именем; передача функции как children — идиоматичная форма.

function Toggle({ children }: { children: (on: boolean, toggle: () => void) => React.ReactNode }) {
  const [on, setOn] = useState(false);
  const toggle = () => setOn((v) => !v);
  return <>{children(on, toggle)}</>; // отдаём состояние + обработчик обратно вызывающему
}

// вызывающий владеет разметкой; Toggle владеет поведением
<Toggle>
  {(on, toggle) => (
    <button onClick={toggle} aria-pressed={on}>{on ? "On" : "Off"}</button>
  )}
</Toggle>

Компонент делится тем, что он делает, не диктуя, как он выглядит.

2

Поток данных — это инверсия: ребёнок вычисляет состояние, затем зовёт наверх функцию родителя, чтобы отрисовать его. Обычные пропсы текут родитель → ребёнок. Render-проп идёт в обратную сторону во время рендера: Toggle держит состояние, но делегирует отрисовку обратно тому, кто его отрендерил. Это инверсия управления — владеющий компонент управляет поведением; вызывающий управляет представлением. Два экрана могут разделять один Toggle и рисовать совершенно разный UI.

// экран A — переключатель
<Toggle>{(on, toggle) => <Switch checked={on} onChange={toggle} />}</Toggle>

// экран B — чекбокс с подписью, то же поведение, другая разметка
<Toggle>{(on, toggle) => (
  <label><input type="checkbox" checked={on} onChange={toggle} /> Notifications</label>
)}</Toggle>

Ни один экран не переписывает логику переключения, и Toggle ничего не знает о переключателях или чекбоксах.

3

Для переиспользования чистой логики кастомный хук делает ту же работу площе — вот почему render-props поблёкли. Render-проп исторически был единственным способом разделить состояние-несущую логику без классового HOC. Хуки сделали поведение вызываемым напрямую, без лишнего компонента и без вложенности. Если всё, что тебе нужно, — это состояние вкл/выкл, верни его из хука и дай потребителю рендерить как обычно.

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn((v) => !v), []);
  return [on, toggle] as const;
}

// нет компонента-обёртки, нет отступа функции-ребёнка — просто значения в области видимости
function NotificationRow() {
  const [on, toggle] = useToggle();
  return <Switch checked={on} onChange={toggle} />;
}

Когда общее — это логика, возвращающая значения, предпочитай хук. Он композируется с другими хуками, не добавляет глубины дереву и читается сверху вниз.

4

Render-props всё ещё правильный инструмент, когда компонент должен внедрить что-то во время рендера. Хук возвращает значения до того, как ты рендеришь; он не может решить, как рисуется каждый элемент внутри структуры, которой управляет библиотека. Когда владеющий компонент владеет циклом рендеринга — виртуализированный список, решающий, какие строки видимы; фетчер, рисующий одну из веток loading/error/data; измеряющий компонент, знающий размер бокса, — он должен позвать тебя обратно во время собственного рендера. Это в точности render-проп.

// библиотека владеет тем, какие строки существуют в этом кадре; ТЫ владеешь разметкой каждой строки
<VirtualList items={users} rowHeight={48}>
  {(user, index) => <UserRow key={user.id} user={user} index={index} />}
</VirtualList>

// фетчер владеет машиной состояний loading/error/data; ты рисуешь каждую ветку
<Query url="/api/users">
  {(state) =>
    state.status === "loading" ? <Spinner />
    : state.status === "error" ? <Error msg={state.error} />
    : <List data={state.data} />}
</Query>

Хук не может сделать это чисто: он не сидит внутри рендера списка или переключателя веток фетчера. Инъекция во время рендера — это непреходящая ниша render-пропа.

Разбор примера

Фетчер данных: render-проп, затем где хук его обыгрывает, затем где нет. Начни с Fetcher, владеющего машиной состояний loading/error/data и отдающего каждую ветку обратно вызывающему.

type State<T> =
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "data"; data: T };

function Fetcher<T>({ url, children }: {
  url: string;
  children: (state: State<T>) => React.ReactNode;
}) {
  const [state, setState] = useState<State<T>>({ status: "loading" });
  useEffect(() => {
    let live = true;
    fetch(url)
      .then((r) => r.json())
      .then((data) => live && setState({ status: "data", data }))
      .catch((e) => live && setState({ status: "error", error: String(e) }));
    return () => { live = false; };
  }, [url]);
  return <>{children(state)}</>;
}

Если потребителю нужны просто данные и статус как значения, хук площе — без обёртки, без отступа, и он композируется с другими хуками:

function useFetch<T>(url: string) { /* та же логика, возвращает state */ return state; }

function UserList() {
  const state = useFetch<User[]>("/api/users"); // значения в области видимости, рендерим как обычно
  if (state.status === "loading") return <Spinner />;
  if (state.status === "error") return <Error msg={state.error} />;
  return <List data={state.data} />;
}

Так что хук здесь выигрывает. Но теперь сложи render-проп-форму на три уровня — фетч внутри виртуализированного списка внутри компонента, измеряющего контейнер, — и отступ марширует прямо за край экрана:

<Measure>{(size) =>
  <Fetcher url="/api/users">{(state) =>
    state.status === "data"
      ? <VirtualList items={state.data} height={size.height}>{(user) =>
          <UserRow user={user} />
        }</VirtualList>
      : <Spinner />
  }</Fetcher>
}</Measure>

Это режим отказа «ад колбэков». Senior-прочтение: Measure, Fetcher и VirtualList здесь не эквивалентны. Fetcher сводится к useFetch — схлопни его. Measure сводится к хуку useMeasure — схлопни и его. Что остаётся — один render-проп на VirtualList, который не может схлопнуться, потому что внедряет каждую строку внутри рендера, которым владеет список. Плоская версия вытягивает два из трёх обратно в хуки верхнего уровня и сохраняет тот один render-проп, которому действительно нужна инъекция во время рендера.

Почему это работает

Почему хук не может заменить случай с виртуализированным списком? Хук выполняется один раз в начале твоего компонента и возвращает значения; у него нет способа участвовать в цикле рендера, которым управляет другой компонент. Виртуализированный список решает — каждый кадр, на основе прокрутки, — какие индексы монтировать, и ему нужна твоя разметка для каждого из этих элементов в тот момент. Единственный способ дать компоненту «разметку для элемента, который он решит отрендерить позже» — передать ему функцию, которую он зовёт во время собственного рендера. Эта функция — render-проп. Та же логика применима ко всему, что владеет структурой и просит тебя заполнить в ней слоты: таблицы, деревья, drag-and-drop, измеряющие обёртки.

Частая ошибка

Рефлекторная ошибка — трактовать «render-props в основном вытеснены» как «render-props устарели», а потом запихивать хук в задачу инъекции-во-время-рендера, в итоге переписывая виртуализацию заново или протягивая тысячу пропсов, чтобы подделать то, что рендерер строки сделал бы в одну строку. Обратная ошибка — тянуться к render-пропу, когда обычный хук (или даже обычные children/слоты) справился бы, платя налог вложенности ни за что. Дисциплина: если общее — это логика, возвращающая значения, используй хук; если владеющий компонент должен позвать тебя обратно во время своего рендера, используй render-проп; если нужно лишь варьировать, куда идут фиксированные children, достаточно обычных children/слотов.

Проверь себя
Викторина

Ты делишь состояние переключателя вкл/выкл между тремя компонентами, которые все рисуют его по-разному, но не нуждаются в цикле рендера под управлением библиотеки — нужны лишь состояние и обработчик переключения. Render-проп или кастомный хук?

Итог

Render-проп — это проп, значение которого — функция, возвращающая JSX: владеющий компонент держит состояние и обработчики, затем зовёт эту функцию обратно во время собственного рендера, передавая состояние внутрь, так что разметкой управляет вызывающий. Эта инверсия управления позволила компонентам делить поведение, не диктуя представления. Для переиспользования чистой логики кастомные хуки вытеснили render-props — хук возвращает те же значения заранее, без обёртки и без вложенности, и композируется с другими хуками, так что предпочитай его всегда, когда общее — это просто логика, возвращающая значения. Render-props остаются правильным инструментом для инъекции во время рендера: рендерер строки виртуализированного списка, фетчер, рисующий ветки loading/error/data, измеряющая обёртка — случаи, где владеющий компонент управляет циклом рендера и должен позвать тебя обратно, чтобы его заполнить, чего хук структурно сделать не может. Режим отказа — ад вложенных render-проп-колбэков; senior-фикс — схлопнуть каждый render-проп, который на деле просто логика, в хук и сохранить лишь тот один (или немногие), которому действительно нужна инъекция во время рендера.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.