Шаблоны проектирования в React

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


Условный рендеринг

Этот один из наиболее простых и распространенных шаблонов с компонентами React. Довольно часто приходится по определенному условию рендерить или не рендерить какой-нибудь код JSX. Например, кнопки с надписью «Войти» для не аутентифицированных пользователей и «Выйти» для уже вошедших в систему.

Обычно в условном рендеринге используют оператор && или ternary.

{condition && <span>Rendered when `truthy`</span>}
{condition ? <span>Rendered when `truthy`</span> : <span>Rendered when `falsy`</span>}

В некоторых случаях можно также предпочесть if, switch или объектные литералы.

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

Хуки React показали свои уникальные возможности в сочетании с функциональными компонентами. Они обеспечивают простой и прямой доступ к таким общим функциям React, как props, state, context refs и жизненный цикл. Конечно, можно довольствоваться и традиционными хуками. Но давайте разберемся в преимуществах пользовательских.

Представьте фрагмент логики, написанный для компонента с использованием таких базовых хуков, как useEffect и useState. Через некоторое время ту же логику приходится использовать в новом компоненте. Копирование может показаться наиболее быстрым и простым способом, но пользовательские хуки с тем же эффектом намного полезнее. Если поместить в хук часто используемую логику, код становится чище, его легче дорабатывать и использовать повторно.

Начнем с распространенного случая использования  —  вызова API в разных компонентах. Представьте компонент, который отображает список пользователей после извлечения данных из API.

const UsersList = () => {
const [data, setData] = useState(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);

const fetchData = async () => {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
const response = await res.json();
setData(response.data);
} catch (error) {
setError(error);
setLoading(false);
} finally {
setLoading(false);
}
};

useEffect(() => {
fetchData();
}, []);

return (...);
};

Поскольку вызовы API в значительной степени являются основой для большинства компонентов, почему бы не извлечь их в одном месте? Эту функциональность можно легко реализовать с помощью нового хука useFetch:

export const useFetch = (url, options) => {
const [data, setData] = useState();
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);

const fetchData = async () => {
try {
const res = await fetch(url, options);
const response = await res.json();
setData(response.data);
} catch (error) {
setError(error);
setLoading(false);
} finally {
setLoading(false);
}
};

useEffect(() => {
fetchData();
}, []);

return { data, error, loading, refetch: fetchData };
};

const UsersList = () => {
const { data, error, loading, refetch } = useFetch(
"https://jsonplaceholder.typicode.com/users"
);

return (...);
};

Некоторые другие варианты использования пользовательских хуков:

  • получение размеров окна;
  • доступ и настройка локального хранилища;
  • переключение между логическими состояниями и т. д.

Шаблон Provider

Одной из основных проблем для разработчиков React является процедура Prop drilling (передача данных через несколько вложенных компонентов). В этом случае данные (props) проходят вниз по иерархии через различные компоненты до искомого компонента. Это может стать проблемой при передаче данных компонентам, которые расположены на несколько уровней ниже, поскольку реализуется излишне усложненная цепочка передач.

Выходом из положения может стать шаблон “Поставщик” (Provider). Он обеспечивает централизованное хранение данных для глобального или совместного использования. Затем поставщик/хранилище может передавать эти данные напрямую (без drilling props) любому компоненту. Встроенный в React Context API основан на этом подходе. Этот шаблон используют библиотеки react-redux, flux, MobX и т. д.

Provider

Например, одним из распространенных сценариев приложений является реализация светлой/темной темы. Без шаблона Provider реализация выглядела бы так:

const App = ({ theme }) => {
return (
<>
<Header theme={theme} />
<Main theme={theme} />
<Footer theme={theme} />
</>
);
};

const Header = ({ theme }) => {
return (
<>
<NavMenu theme={theme} />
<PreferencesPanel theme={theme} />
</>
);
};

Все упрощается с Context API.

const ThemeContext = createContext("light", () => "light");

const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export { ThemeContext, ThemeProvider };

const App = () => {
return (
<ThemeProvider>
<Header />
<Main />
<Footer />
</ThemeProvider>
);
};
const PreferencesPanel = () => {
const { theme, setTheme } = useContext(ThemeContext);

...
};

Другие возможные варианты использования шаблона Provider:

  • управление состоянием аутентификации;
  • управление настройками выбора локализации/языка и т. д.

Шаблон HOC

HOC (Higher-Order Component) в React  —  продвинутый метод повторного использования логики в компонентах. Это шаблон на основе композиционного характера React, включающий принцип программирования DRY (Don’t Repeat Yourself). Подобно функциям высшего порядка в JS, HOC  —  это чистые функции, которые принимают компонент в качестве аргумента и возвращают улучшенный и обновленный компонент. Это соответствует природе функциональных компонентов React, то есть композиции важнее наследования. Некоторые практические примеры:

  • react-redux: connect(mapStateToProps, mapDispatchToProps)(UserPage);
  • react-router: withRouter(UserPage);
  • material-ui: withStyles(styles)(UserPage).
HOC

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

const UsersList = ({ hasError, isLoading, data }) => {
const { users } = data;
if (isLoading) return <p>Loading…</p>;
if (hasError) return <p>Sorry, data could not be fetched.</p>;
if (!data) return <p>No data found.</p>;

return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};

const { data, loading, error } = fetchData();
<UsersList {...{ data, error }} isLoading={loading} />;

Отображение таких разных состояний выборки API  —  это общая логика. Ее можно легко повторно использовать во многих компонентах, включая HOC. Например:

const withAPIFeedback =
(Component) =>
({ hasError, isLoading, data }) => {
if (isLoading) return <p>Loading…</p>;
if (hasError) return <p>Sorry, data could not be fetched.</p>;
if (!data) return <p>No data found.</p>;
return <Component {...{ data }} />;
};

const UsersList = ({ data }) => {
const { users } = data;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};

const { data, loading, error } = fetchData();
const UsersListWithFeedback = withAPIFeedback(UsersList);
<UsersListWithFeedback {...{ data, error }} isLoading={loading} />;

HOC полезны при решении сквозных проблем, особенно для повторно используемой логики компонентов в приложении. Например:

  • реализация механизмов регистрации;
  • управление авторизациями и т. д.

Шаблон Presentational & Container Components

Этот подход предполагает разделение компонентов на две разные категории и стратегии реализации.

  • Компоненты представления. Это чисто функциональные компоненты без памяти состояния. Они отвечают за презентацию, отображение данных, не имеют никаких зависимостей с другими частями приложения.
  • Компоненты контейнера. В отличие от компонентов представления, компоненты контейнера отвечают в первую очередь за функционирование сущностей. Они действуют как контейнер для любых побочных эффектов, логики хранения состояния и самих компонентов представления.

При таком подходе улучшается разделение задач (из-за отсутствия единого сложного компонента, обрабатывающего и рендеринг, и логические состояния). Кроме того, улучшается возможность повторного использования компонентов представления (поскольку у них нет никаких зависимостей, их можно легко использовать повторно в нескольких сценариях).

Presentational & Container Component

Таким образом, разработчик должен стремиться к созданию компонентов без хранения состояния, даже если нет конкретной необходимости в повторном использовании этих компонентов. В иерархии компонентов предпочтительнее сохранять состояние в родительских компонентах, создавая дочерние компоненты без хранения состояния.

Например, любой компонент, отображающий список, может быть компонентом представления:

const ProductsList = ({ products }) => {
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
};

Соответствующим компонентом контейнера при этом может быть:

const ProductsCatalog = () => {
const [products, setProducts] = useState([]);

useEffect(() => {
fetchProducts();
}, []);

return <ProductsList {...{ products }} />;
};

Шаблон Controlled & Uncontrolled Component

Формы ввода данных используются во многих приложениях. React предлагает два способа обработки таких данных в компонентах.

  • Первый предполагает использование состояния React внутри компонента для обработки данных формы. Это контролируемый компонент.
  • Второй известен как неконтролируемый компонент и позволяет DOM самостоятельно обрабатывать данные формы в компоненте. “Неконтролируемый” означает, что эти компоненты контролируются не состоянием React, а скорее традиционными мутациями DOM.

Пример неконтролируемого компонента:

function App() {
const nameRef = useRef();
const emailRef = useRef();

const onSubmit = () => {
console.log("Name: " + nameRef.current.value);
console.log("Email: " + emailRef.current.value);
};

return (
<form onSubmit={onSubmit}>
<input type="text" name="name" ref={nameRef} required />
<input type="email" name="email" ref={emailRef} required />
<input type="submit" value="Submit" />
</form>
);
}

Для доступа к данным входа здесь используется ref. При необходимости приходится извлекать значение из поля. Вот как выглядит управляемая версия такой формы:

function App() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");

const onSubmit = () => {
console.log("Name: " + name);
console.log("Email: " + email);
};

return (
<form onSubmit={onSubmit}>
<input
type="text"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<input
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<input type="submit" value="Submit" />
</form>
);
}

Значение поля ввода здесь всегда определяется состоянием React. Этот поток как бы передает изменения значений компоненту формы. Поэтому он всегда имеет текущее значение ввода без явного запроса. И хотя для этого придется написать немного больше кода, теперь вы можете передать значение и другим элементам пользовательского интерфейса или сбросить его из других обработчиков событий, например с помощью пропсов и обратных вызовов событий.

Элементы формы в React поддерживают как контролируемые, так и неконтролируемые компоненты. Работая с простым пользовательским интерфейсом и обратной связью, в некоторых случаях можно предпочесть неконтролируемые компоненты. Для сложной логики предпочтительнее использовать управляемые компоненты.

Шаблон Render Props

Согласно официальной документации React, Render Prop  —  метод совместного использования кода между компонентами с помощью prop, значением которого является функция. HOC и Render Props служат одной цели: решению сквозных задач путем совместного использования логики с отслеживанием состояния между компонентами.

Компонент, реализующий этот шаблон, принимает функцию, возвращающую React Element в качестве пропса, и вызывает его вместо использования логики рендеринга. Таким образом, вместо прямого программирования логики внутри каждого компонента, мы можем использовать функцию prop для определения объекта рендеринга.

Render Props

В качестве пояснения рассмотрим пример. Предположим, имеется список продуктов для рендеринга в разных местах приложения. Пользовательский интерфейс в них разный, однако логика одна и та же  —  получение продуктов из API и рендеринг списка.

const ProductsSection = () => {
const [products, setProducts] = useState([]);

const fetchProducts = async () => {
try {
const res = await fetch("https://dummyjson.com/products");
const response = await res.json();
setProducts(response.products);
} catch (e) {
console.error(e);
}
};

useEffect(() => {
fetchProducts();
}, []);

return (
<ul>
{products.map((product) => (
<li key={product.id}>
<img src={product.thubmnail} alt={product.name} />
<span>{product.name}</span>
</li>
))}
</ul>
);
};

const ProductsCatalog = () => {
const [products, setProducts] = useState([]);

const fetchProducts = async () => {
try {
const res = await fetch("https://dummyjson.com/products");
const response = await res.json();
setProducts(response.products);
} catch (e) {
console.error(e);
}
};

useEffect(() => {
fetchProducts();
}, []);

return (
<ul>
{products.map((product) => (
<li key={product.id}>
<span>Brand: {product.brand}</span>
<span>Trade Name: {product.name}</span>
<span>Price: {product.price}</span>
</li>
))}
</ul>
);
};

С помощью шаблона Render Props легко повторно использовать эту функциональность:

const ProductsList = ({ renderListItem }) => {
const [products, setProducts] = useState([]);

const fetchProducts = async () => {
try {
const res = await fetch("https://dummyjson.com/products");
const response = await res.json();
setProducts(response.products);
} catch (e) {
console.error(e);
}
};

useEffect(() => {
fetchProducts();
}, []);

return <ul>{products.map((product) => renderListItem(product))}</ul>;
};

// Products Section
<ProductsList
renderListItem={(product) => (
<li key={product.id}>
<img src={product.thumbnail} alt={product.title} />
<div>{product.title}</div>
</li>
)}
/>

// Products Catalog
<ProductsList
renderListItem={(product) => (
<li key={product.id}>
<div>Brand: {product.brand}</div>
<div>Name: {product.title}</div>
<div>Price: $ {product.price}</div>
</li>
)}
/>

Шаблон Render Props используют такие популярные библиотеки, как React Router, Formik и Downshift.

Шаблон “Составные компоненты”

Шаблон “Составные компоненты” (Compound components)  —  это расширенный шаблон контейнера React, обеспечивающий при совместной работе нескольких компонентов простой и эффективный способ совместного использования состояний и обработки логики. Создаваемый с его помощью гибкий API позволяет родительскому компоненту неявно взаимодействовать и делиться состоянием с дочерними элементами. Шаблон лучше всего подходит для приложений React, где необходимо создавать декларативный пользовательский интерфейс. Он также используется в некоторых популярных библиотеках, включая Ant-Design, Material UI и т. д.

Этот шаблон проще понять на примере традиционных элементов select и options в HTML. Они действуют однотипно, предоставляя раскрывающееся поле формы. Элемент select управляет своим состоянием и неявно делится им с элементами options. Следовательно, хотя явного объявления состояния нет, элемент select знает, какой вариант выбирает пользователь. При необходимости таким же образом можно применять Context API для совместного использования и управления состоянием между родительским и дочерним компонентами.

Compound Components

Чтобы разобраться конкретнее, реализуем компонент Tab как Compound Component. Обычно имеется ассоциированный с контентом список Tab. Одновременно активен только 1 Tab и видно его содержимое. Вот как это можно сделать:

const TabsContext = createContext({});

function useTabsContext() {
const context = useContext(TabsContext);
if (!context) throw new Error(`Tabs components cannot be rendered outside the TabsProvider`);
return context;
}

const TabList = ({ children }) => {
const { onChange } = useTabsContext();

const tabList = React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return null;
return React.cloneElement(child, {
onClick: () => onChange(index),
});
});

return <div className="tab-list-container">{tabList}</div>;
};

const Tab = ({ children, onClick }) => (
<div className="tab" onClick={onClick}>
{children}
</div>
);

const TabPanels = ({ children }) => {
const { activeTab } = useTabsContext();

const tabPanels = React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return null;
return activeTab === index ? child : null;
});

return <div className="tab-panels">{tabPanels}</div>;
};

const Panel = ({ children }) => (
<div className="tab-panel-container">{children}</div>
);

const Tabs = ({ children }) => {
const [activeTab, setActiveTab] = useState(0);

const onChange = useCallback((tabIndex) => setActiveTab(tabIndex), []);
const value = useMemo(() => ({ activeTab, onChange }), [activeTab, onChange]);

return (
<TabsContext.Provider value={value}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
};

Tabs.TabList = TabList;
Tabs.Tab = Tab;
Tabs.TabPanels = TabPanels;
Tabs.Panel = Panel;
export default Tabs;

Теперь этот код выглядит так:

const App = () => {
const data = [
{ title: "Tab 1", content: "Content for Tab 1" },
{ title: "Tab 1", content: "Content for Tab 1" },
];

return (
<Tabs>
<Tabs.TabList>
{data.map((item) => (
<Tabs.Tab key={item.title}>{item.title}</Tabs.Tab>
))}
</Tabs.TabList>
<Tabs.TabPanels>
{data.map((item) => (
<Tabs.Panel key={item.title}>
<p>{item.content}</p>
</Tabs.Panel>
))}
</Tabs.TabPanels>
</Tabs>
);
};

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

  • списки и элементы списков;
  • меню и заголовки меню, пункты меню, разделители;
  • таблица и заголовок таблицы, блок, строка и ячейка таблицы;
  • связь заголовка с содержанием;
  • переключатели и кнопки.

Шаблон Layout Components

Большинство страниц в приложениях и на сайтах React используют один и тот же контент. Например, панель навигации и нижний колонтитул страницы. Вместо импорта и рендеринга каждого компонента на все страницы, гораздо проще и быстрее просто создать компонент макета (layout component). Такие компоненты позволяют использовать общие разделы на нескольких страницах. Как следует из названия, они определяют макет приложения.

Layout Components

Повторно используемые макеты  —  очень хороший практический прием, позволяющий использовать однажды написанный код во многих частях приложения. Например, можно повторно использовать макеты на основе системы Grid или модели Flex Box.

А пока рассмотрим базовый пример компонента макета, с помощью которого можно совместно использовать Header и Footer на нескольких страницах.

const PageLayout = ({ children }) => {
return (
<>
<Header />
<main>{children}</main>
<Footer />
</>
);
};

const HomePage = () => {
return <PageLayout>{/* Page content goes here */}</PageLayout>;
};

Повторно используемые компоненты, удобные инструменты разработчика и обширная экосистема  —  вот лишь некоторые из наиболее популярных функций React.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Roopal Jasnani: Understanding Design Patterns in React

Предыдущая статьяПроизводительность Redis и атомарность в Golang. Возможности конвейеров, транзакций и Lua-скриптов
Следующая статьяПродвинутое применение «select» в Ruby