Render Props - это довольно полезный паттерн, который был очень популярен несколько лет назад, но сейчас его используют редко. Тем не менее я решил поделиться своим опытом, так как его использование очень помогло мне в одном из моих последних проектов. Его суть заключается в том, что мы можем переиспользовать определенную логику рендеринга компонентов. Также как и в случае с паттерном HOC, он часто использовался во времена pre-hooks. Но и в наше время ему можно найти применение, помимо совместного использования stateful логини, он также может быть использован для абстрагирования некоторой функциональности и позволяет разработчикам предоставить свой собственный JSX.
Чтобы продемонстрировать, как работает паттерн Render Props, мы создадим компонент ListEntity, который отвечает за рендеринг списка сущностей.
Сущностью является любой тип, который наследует корневой интерфейс Entity:
export type Entity = {
id: string;
assigned_user_id: string;
source: string;
label: string;
tags: string[];
starred: boolean;
description: string;
createdtime: string;
modifiedtime: string;
};
Наша задача создать такой компонент, который бы стандартизировал вывод списка сущностей. Итак, наш основной компонент, создаем файл src/features/misc/components/ListEntities.tsx со следующим содержимым:
import { ReactNode } from 'react';
import { Badge, Card, Col, Row } from 'react-bootstrap';
import { ScrollByCount } from '../../../components/Scrollspy/ScrollByCount.tsx';
import { formatToUserReadableDate } from '../services/Dates.ts';
import { Entity } from '../types/entity.ts';
interface ListEntitiesProps<P extends Entity> {
entities: P[];
headerExtractor: (entity: P) => string;
statusExtractor: (entity: P) => string;
scrollCount?: number;
renderEntity?: (entity: P) => ReactNode;
}
export const ListEntities = <P extends Entity>({
entities,
headerExtractor,
statusExtractor,
renderEntity,
scrollCount = 3,
}: ListEntitiesProps<P>) => {
return (
<ScrollByCount count={scrollCount}>
{entities.map((entity) =>
renderEntity ? (
renderEntity(entity)
) : (
<Card key={entity.id} className="mb-2 sh-11 sh-md-8">
<Card.Body className="pt-0 pb-0 h-100">
<Row className="g-0 h-100 align-content-center">
<Col md="3" className="d-flex align-items-center mb-2 mb-md-0">
{headerExtractor(entity)}
</Col>
<Col
xs="5"
md="4"
className="d-flex align-items-center text-medium justify-content-start justify-content-md-center text-muted"
>
{entity.label}
</Col>
<Col
xs="5"
md="3"
className="d-flex align-items-center justify-content-center text-muted"
>
{formatToUserReadableDate(entity.createdtime)}
</Col>
<Col
xs="2"
md="2"
className="d-flex align-items-center text-muted text-medium mb-1 mb-md-0 justify-content-end"
>
<Badge bg="outline-primary" className="py-1 px-3 text-small lh-1-5">
{statusExtractor(entity)}
</Badge>
</Col>
</Row>
</Card.Body>
</Card>
)
)}
</ScrollByCount>
);
};
В данном случае наш компонент принимает 3 параметра:
- entities - массив элементов для отображения, они должны наследовать тип Entity.
- headerExtractor - функция, которая возвращаем нам значение для отображения заголовка в таблице.
- statusExtractor - позволяет отображать статус в таблице.
- renderItem - необязательная функция, которая предоставляет JSX для списка элементов.
Как вы можете заметить, у компонента нет состояния, поэтому тестировать его очень просто:
import { render, screen } from '@testing-library/react';
import { expect, test, describe } from 'vitest';
import '@testing-library/jest-dom';
import { Entity } from '../types/entity.ts';
import { ListEntities } from './ListEntities.tsx';
describe('ListEntities', () => {
const entities: Entity[] = [
{
id: '1',
label: 'Entity 1',
createdtime: '2022-01-01T00:00:00Z',
description: 'Description 1',
assigned_user_id: '19x234',
source: 'Source 1',
tags: [],
modifiedtime: '2022-01-01T00:00:00Z',
starred: false,
},
{
id: '2',
label: 'Entity 2',
createdtime: '2022-01-02T00:00:00Z',
description: 'Description 2',
assigned_user_id: '19x554',
source: 'Source 2',
tags: [],
modifiedtime: '2022-01-01T00:00:00Z',
starred: false,
},
];
const headerExtractor = (entity: Entity) => entity.assigned_user_id;
const statusExtractor = (entity: Entity) => entity.source;
test('should render entities with default structure', () => {
const view = render(
<ListEntities
entities={entities}
headerExtractor={headerExtractor}
statusExtractor={statusExtractor}
/>
);
const entity1Header = screen.getByText('Entity 1');
const entity2Header = screen.getByText('Entity 2');
const entity1Status = screen.getByText('Source 1');
const entity2Status = screen.getByText('Source 2');
const entity1Assign = screen.getByText('19x234');
const entity2Assign = screen.getByText('19x554');
expect(entity1Header).toBeInTheDocument();
expect(entity2Header).toBeInTheDocument();
expect(entity1Status).toBeInTheDocument();
expect(entity2Status).toBeInTheDocument();
expect(entity1Assign).toBeInTheDocument();
expect(entity2Assign).toBeInTheDocument();
view.unmount();
});
test('renders custom entities using the renderEntity prop', () => {
const customRenderEntity = (entity: Entity) => (
<div key={entity.id}>Custom Entity: {entity.label}</div>
);
render(
<ListEntities
entities={entities}
headerExtractor={headerExtractor}
statusExtractor={statusExtractor}
renderEntity={customRenderEntity}
/>
);
const customEntity1 = screen.getByText('Custom Entity: Entity 1');
const customEntity2 = screen.getByText('Custom Entity: Entity 2');
expect(customEntity1).toBeInTheDocument();
expect(customEntity2).toBeInTheDocument();
});
});
Итак, теперь мы можем спокойно создать компонент ProjectTasks, который будет использовать ListEntities для отображения списка проектных задач. Создадим файл src/features/task/components/ProjectTasks.tsx со следующим содержимым:
import { Badge, Card, Col, Row, Spinner } from 'react-bootstrap';
import { FormattedMessage } from 'react-intl';
import { NavLink } from 'react-router-dom';
import { DEFAULT_PATHS } from '../../../config';
import { ListEntities } from '../../misc/components/ListEntities.tsx';
import { Project } from '../../project/types';
import { useTasksFromProject } from '../api/getFromProject.ts';
import { ProjectTask } from '../types';
interface ProjectTasksProps {
project: Project;
}
export const ProjectTasks = ({ project }: ProjectTasksProps) => {
const tasksQuery = useTasksFromProject({ projectId: project.id });
if (tasksQuery.isLoading) {
return <Spinner animation="border" variant="primary"></Spinner>;
}
if (!tasksQuery.data || tasksQuery.data.data.length < 1) {
return (
<p>
<FormattedMessage id="general.no-data"></FormattedMessage>
</p>
);
}
return (
<ListEntities
entities={tasksQuery.data.data}
headerExtractor={(task: ProjectTask) => task.projecttask_no}
statusExtractor={(task: ProjectTask) => task.projecttaskstatus}
renderEntity={(task: ProjectTask) => (
<Card key={task.id} className="mb-2 sh-11 sh-md-8">
<Card.Body className="pt-0 pb-0 h-100">
<Row className="g-0 h-100 align-content-center">
<Col md="3" className="d-flex align-items-center mb-2 mb-md-0">
<NavLink
to={`${DEFAULT_PATHS.PROJECT}/${project.id}/tasks/${task.id}`}
className="body-link text-truncate stretched-link"
>
{task.projecttask_no}
</NavLink>
</Col>
<Col
xs="5"
md="4"
className="d-flex align-items-center text-medium justify-content-start justify-content-md-center text-muted"
>
{task.projecttaskname}
</Col>
<Col
xs="5"
md="3"
className="d-flex align-items-center justify-content-center text-muted"
>
{task.projecttasktype}
</Col>
<Col
xs="2"
md="2"
className="d-flex align-items-center text-muted text-medium mb-1 mb-md-0 justify-content-end"
>
<Badge bg="outline-primary" className="py-1 px-3 text-small lh-1-5">
{task.projecttaskstatus}
</Badge>
</Col>
</Row>
</Card.Body>
</Card>
)}
></ListEntities>
);
};
Массив сущностей entities нам приходит из API через ReactQuery. Перед отображением списка, мы проверяем, является ли он пустым или идет процесс загрузки. В это случае мы отображаем соответствующее сообщение. Если массив не пустой, мы используем наш компонент ListEntities, передаем ему проектные задачи. В качестве параметра headerExtractor и statusExtractor мы передаем соответствующие поля проектной задачи. И напоследок, мы передаем в функцию renderEntity верстку карточки проектной задачи с нашими кастомными стилыми. Учтите, что эту функцию можно не использовать, в этом случае мы получим верстку по умолчанию.
Render Props - это действительно полезный паттерн, который можно использовать для создания гибких компонентов, позволяющих разработчикам переопределять часть или все содержимое компонентов. Обратите внимание, что не всегда нужно требовать, чтобы в компонент был предоставлен JSX через параметры, потому что если он предоставлен не был, всегда можно сгенерировать содержимое по умолчанию.