Render Props паттерн в React.js

Render Props паттерн в React.js

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 через параметры, потому что если он предоставлен не был, всегда можно сгенерировать содержимое по умолчанию.

Популярное

Самые популярные посты

Как быть максимально продуктивным на удалённой работе?
Business

Как быть максимально продуктивным на удалённой работе?

Я запустил собственный бизнес и намеренно сделал всё возможное, чтобы работать из любой точки мира. Иногда я сижу с своём кабинете с большим 27-дюймовым монитором в своей квартире в г. Чебоксары. Иногда я нахожусь в офисе или в каком-нибудь кафе в другом городе.

Привет! Меня зовут Сергей Емельянов и я трудоголик
Business PHP

Привет! Меня зовут Сергей Емельянов и я трудоголик

Я программист. В душе я предприниматель. Я начал зарабатывать деньги с 11 лет, в суровые 90-е годы, сдавая стеклотару в местный магазин и обменивая её на сладости. Я зарабатывал столько, что хватало на разные вкусняшки.

Акция! Профессиональный разработчик CRM за 2000 руб. в час

Выделю время под ваш проект. Знания технологий Vtiger CRM, SuiteCRM, Laravel, Vue.js, Golang, React.js. Предлагаю варианты сотрудничества, которые помогут вам воспользоваться преимуществами внешнего опыта, оптимизировать затраты и снизить риски. Полная прозрачность всех этапов работы и учёт временных затрат. Оплачивайте только рабочие часы разработки после приемки задачи. Экономьте на платежах по его содержанию разработчика в штате. Возможно заключение договора по ИП. С чего начать, чтобы нанять профессионального разработчика на full-time? Просто заполните форму!

Telegram
@sergeyem
Telephone
+4915211100235