Предисловие
Не часто приходится встречать людей, которые понимают зачем он нужен и ещё реже попадаются проекты, в которых его используют. А шаблон-то очень полезный!
В этой статье вы поймёте как он устроен, зачем он нужен и на примерах научитесь его правильно применять.
Как он устроен?
Главная смысл шаблона - передача в качестве 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:
Возможность неограниченного переиспользования логики компонента с другим UI без надобности создания клона компонента.
Компонент не перегружается теми UI, которые ему не нужны и используются только в единичных случаях.
Минусы Render props:
При использовании сложных или вложенных друг в друга Render props ухудшается читабельность кода.
Плюсы пользовательских хуков:
Возможность переиспользования "логики" между разными компонентами.
Минусы пользовательских хуков:
В случае, когда на странице необходимо отрендерить большое количество однотипных элементов, но с небольшими различиями в UI, придётся для каждого создать отдельный компонент или под каждый такой блок кода сверху родительского компонента объявить пользовательский хук, что является нежелательным, так как каждое изменение состояния даже в одном из множества таких хуков будет вызывать перерендеринг всего компонента.
Итог
Как вы могли заметить из примеров выше, шаблон Render props - это очень полезная фича! Иногда её действительно можно использовать вместо пользовательских хуков, а иногда можно комбинировать вместе.
