Предисловие

Не часто приходится встречать людей, которые понимают зачем он нужен и ещё реже попадаются проекты, в которых его используют. А шаблон-то очень полезный!

В этой статье вы поймёте как он устроен, зачем он нужен и на примерах научитесь его правильно применять.

Как он устроен?

Главная смысл шаблона - передача в качестве props функции, которая будет принимать какие-то данные от дочернего компонента и отрисовывать их так, как будет указано в родительском.

Например:

const ParentComponent = () => {
  return (
    <ChildComponent
      render={(text) => <h1>{text}</h1>}
    />
  );
};

const ChildComponent = ({ render }) => {
  const text = "Hello World";
  return <div>{render(text)}</div>;
};


  
// Получаем такой код
<div>
  <h1>Hello World</h1>
</div>


  
// В качестве названия рендер-пропса можно использовать
// любой текст. "render" в примере используется исключительно 
// в целях удобства понимания.

На 4 строке видно, что переданный text из ChildComponent мы отрисовываем внутри <h1> тега, но это всего лишь наименьшая обёртка, сделанная для простоты примера. Мы можем манипулировать получаемыми данными как-угодно!

Например, добавить какой-то статический текст или стили:

const ParentComponent = () => {
  return (
    <ChildComponent
      render={(text) => {
        return (
          <div style={{color: "#7d7d7d"}}>
            <h1>{text}</h1>
            <div>Какое-то описание...</div>
          </div>
        )
      }}
    />
  );
};

const ChildComponent = ({ render }) => {
  const text = "Hello World";
  return <div>{render(text)}</div>;
};



// Получаем такой код
<div>
  <h1 style="color: #7d7d7d;">Hello World</h1>
  <div>Какое-то описание...</div>
</div>

Зачем он нужен?

Как видно из примера выше, данный шаблон даёт нам больше гибкости в том, как мы можем отобразить содержимое нужного нам компонента.

Это может быть особенно удобным в тех случаях, когда у нас есть компонент с каким-то определённым UI и какой-то определённой "логикой" внутри, но на отдельных страницах его UI должен быть чуточку другим, а механизм работы должен остаться тем же.

Гипотетический пример

Для разминки сначала разберём простой гипотетический пример со счётчиком кликов.

У нас есть базовая "логика" в виде count, setCount и increment. И мы сразу прокидываем эту логику наружу к внешнему компоненту при помощи функции render:

const ClickCounter = ({ render }) => {
  const [count, setCount] = useState(0);

  const increment = () => setCount(count + 1);

  return <div>{render({ count, increment })}</div>
};

Во внешнем компоненте мы эти данные получаем и отрисовываем любым удобным для нас образом:

<ClickCounter
  render={({ count, increment }) => (
    <div>
      <h2>Кастомный счётчик</h2>
      <p>Количество кликов: {count}</p>
      <button onClick={increment}>Прибавить 1</button>
    </div>
  )}
/>

Реальные примеры

До этого мы рассматривали примеры только с обязательным пропсом render. Но мы также можем сделать его необязательным и отрисовывать какой-то UI по-умолчанию в том случае, если он не был передан. В следующих трёх примерах мы как раз рассмотрим этот подход.

1. <Form />

Рассмотрим вот такой компонент для отправки введённых значений. Он хранит в себе функции handleChange и handleSubmit для обработки данных, а также UI, который рендерится самостоятельно в том случае, если функция render не была передана внутрь, иначе данные пробрасываются наружу и могут быть отрендерены как-угодно компонентом выше.

const Form = ({ initialValues, render }) => {
  const [values, setValues] = useState(initialValues);

  const handleChange = (event) => {
    const { name, value } = event.target;
    setValues((previousValues) => ({ ...previousValues, [name]: value }));
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log("Отправленные значения", values);
  };

  if (render) {
    return render({
      values,
      handleChange,
      handleSubmit,
    });
  } 
    
return (
  <form onSubmit={handleSubmit}>
    {Object.keys(initialValues).map((key) => (
      <div key={key}>
        <label>
          <div>{key[0].toUpperCase() + key.slice(1)}:</div>
          <input
            type="text"
            name={key}
            value={values[key]}
            onChange={handleChange}
          />
        </label>
      </div>
    ))}
    <button type="submit">Отправить</button>
  </form>
  );
};

Чтобы получить UI, который компонент предоставляет по-умолчанию, мы можем воспользоваться вот такой конструкцией:

<Form initialValues={{ username: "", email: "" }} />

Если нам понадобится кастомный UI, то мы можем воспользоваться пропсом render:

<Form
  initialValues={{ username: "", email: "" }}
  render={({ values, handleChange, handleSubmit }) => (
    <form onSubmit={handleSubmit}>
      <h2>Кастомная форма</h2>
      
      <div>
        <label>
          <div>Пользователь:</div>
          <input
            type="text"
            name="username"
            value={values.username}
            onChange={handleChange}
          />
        </label>
      </div>

    <div>
      <label>
        <div>Электронная почта:</div>
        <input
          type="email"
          name="email"
          value={values.email}
          onChange={handleChange}
        />
      </label>
    </div>

    <button type="submit">Отправить</button>
  </form>
  )}
/>

2. <Pagination />

Компонент пагинации по способу определения компонента аналогичен Form, но содержит другую "логику":

const Pagination = ({ totalItems, itemsPerPage, render }) => {
  const [currentPage, setCurrentPage] = useState(1);
  const totalPages = Math.ceil(totalItems / itemsPerPage);

  const goToPage = (page) => {
    if (page >= 1 && page <= totalPages) {
      setCurrentPage(page);
    }
  };

  if (render) {
    return render({ currentPage, totalPages, goToPage });
  }
    
  return (
    <div>
      <p>
        Страница {currentPage} из {totalPages}
      </p>
      <button
        onClick={() => goToPage(currentPage - 1)}
        disabled={currentPage === 1}
      >
        Назад
      </button>
      <button
        onClick={() => goToPage(currentPage + 1)}
        disabled={currentPage === totalPages}
      >
        Вперёд
      </button>
    </div>
  );
};

Определение компонента с UI, предоставляемым по умолчанию:

<Pagination totalItems={100} itemsPerPage={10} />

Определение компонента с кастомным UI:

<Pagination
  totalItems={100}
  itemsPerPage={10}
  render={({ currentPage, totalPages, goToPage }) => (
    <div>
      <h2>Кастомная пагинация</h2>
      <button onClick={() => goToPage(1)} disabled={currentPage === 1}>
        Первая
      </button>
      <button
        onClick={() => goToPage(currentPage - 1)}
        disabled={currentPage === 1}
      >
        Назад
      </button>
      <span>
        Страница {currentPage} из {totalPages}
      </span>
      <button
        onClick={() => goToPage(currentPage + 1)}
        disabled={currentPage === totalPages}
      >
        Вперёд
      </button>
      <button
        onClick={() => goToPage(totalPages)}
        disabled={currentPage === totalPages}
      >
        Последняя
      </button>
    </div>
  )}
/>

3. <CopyToClipboard />

Компонент CopyToClipboard также аналогичен предыдущим двум по способу определения компонента, но содержит другую "логику" внутри:

const CopyToClipboard = ({ text, render }) => {
  const [copied, setCopied] = useState(false);

  const handleCopy = async () => {
    try {
      await navigator.clipboard.writeText(text);
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch (error) {
      console.error("Ошибка копирования текста:", error);
    }
  };

  if (render) {
    return render({ copied, handleCopy });
  }

  return (
    <div>
      <p>Текст для копирования: {text}</p>
      <button onClick={handleCopy}>
        {copied ? "Скопировано!" : "Скопировать"}
      </button>
    </div>
  );
}

Определение компонента с UI, предоставляемым по умолчанию:

<CopyToClipboard text="https://example.com" />

Определение компонента с кастомным UI:

<CopyToClipboard
  text="https://example.com"
  render={({ copied, handleCopy }) => (
    <div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
      <input
        type="text"
        value="https://example.com"
        readOnly
        style={{ padding: "5px", width: "300px" }}
      />
      <button onClick={handleCopy}>
        {copied ? "Скопировано!" : "Копировать"}
      </button>
    </div>
  )}
/>

Render props VS Пользовательские хуки

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

Так, ClickCounter из примера выше, можно было бы переделать таким образом:

const useClickCounter = () => {
  const [count, setCount] = useState(0);

  const increment = () => setCount(count + 1);

  return { count, increment };
};

И использовать вот так:

const SomeComponent = () => {
  const { count, increment } = useClickCounter();

  return (
    <div>
      <h2>Кастомный счётчик</h2>
      <p>Количество кликов: {count}</p>
      <button onClick={increment}>Прибавить 1</button>
    </div>
  );
};

Но всё же, подходы не равны на 100% и у каждого есть как свои преимущества, так и недостатки.

Плюсы Render props:

  1. Возможность неограниченного переиспользования логики компонента с другим UI без надобности создания клона компонента.

  2. Компонент не перегружается теми UI, которые ему не нужны и используются только в единичных случаях.

Минусы Render props:

  1. При использовании сложных или вложенных друг в друга Render props ухудшается читабельность кода.

Плюсы пользовательских хуков:

  1. Возможность переиспользования "логики" между разными компонентами.

Минусы пользовательских хуков:

  1. В случае, когда на странице необходимо отрендерить большое количество однотипных элементов, но с небольшими различиями в UI, придётся для каждого создать отдельный компонент или под каждый такой блок кода сверху родительского компонента объявить пользовательский хук, что является нежелательным, так как каждое изменение состояния даже в одном из множества таких хуков будет вызывать перерендеринг всего компонента.

Итог

Как вы могли заметить из примеров выше, шаблон Render props - это очень полезная фича! Иногда её действительно можно использовать вместо пользовательских хуков, а иногда можно комбинировать вместе.

Комментарии (7)


  1. lear
    26.01.2025 19:06

    Всё таки это чаще используется для рендера массива данных, но когда нужна так же некоторая логика (к примеру фильтрация) из дочернего компонента.
    К примеру: https://reactnative.dev/docs/flatlist#required-renderitem

    Если рендер проп используется для рендера компонента с данными из дочернего, то я чаще встречал когда метод передается в качестве children, а не кастомного пропса.
    К примеру: https://www.react-spring.dev/docs/components и https://v5.reactrouter.com/web/api/match/null-matches

    Т.е. по факту это скрывает реализацию. Отсюда вытекают плюсы и минусы.

    Нуу и не хватает разбора с подходом в виде проброса компонента вместо функции. Т.е. сравнение проброса компонента, который принимает такие же пропсы, как и аргументы пробрасываемой функции.


    1. gsaw
      26.01.2025 19:06

      Всё таки это чаще используется для рендера массива данных

      Да, мне все же кажется пробрасывать внутрь функцию отрисовки, то уж либо для отрисовки списка или что то вроде того или отрисовать по условию. Причем я как то делал наоборот, дочерняя компонента, говорила родителю, как отрисовать кнопку экспорта документа.

      А пробрасывать компонент, это уже безусловная отрисовка, и да, я чаще видел и использовал children + плюс общий контекст.


      1. lear
        26.01.2025 19:06

        А пробрасывать компонент, это уже безусловная отрисовка

        Почему же? =)

        <List renderItem={(item) => <ListItem item={item} />} />
        <List ListItem={ListItem} />

        Сравнение двух подходов, когда у нас в качестве рендера передается функция и когда в качестве рендера передается компонент.
        Возможно вы компонент перепутали с элементом?


      1. Viktor9354 Автор
        26.01.2025 19:06

        Спасибо, полезный комментарий!


    1. Viktor9354 Автор
      26.01.2025 19:06

      Спасибо большое за такой развёрнутый и конструктивный комментарий! Полностью согласен.


  1. jbourne
    26.01.2025 19:06

    Хорошая статья. Еще можно это использовать при отрисовке попапов и модальных окон (параметром передавать контент попапа).

    И рендеринга можно передавать и функцию, и компонент и элемент, как и писалось выше. Есть разные плюсы у разных методов.

    П.С. У вас "Плюсы пользовательских хуков" дважды в конце.


    1. Viktor9354 Автор
      26.01.2025 19:06

      Спасибо! Согласен с вами. Опечатку поправил :)