Render Props pattern in React.js

Render Props pattern in React.js

Render Props is a pretty useful pattern that was very popular a few years ago, but is rarely used now. Nevertheless, I decided to share my experience, because its usage helped me a lot in one of my recent projects. Its essence is that we can reuse certain component rendering logic. Just as with the HOC pattern, it was often used in the pre-hooks days. But even nowadays it has its uses; besides sharing stateful logic, it can also be used to abstract some functionality and allow developers to provide their own JSX.

To demonstrate how the Render Props pattern works, we'll create a ListEntity component which is responsible for rendering a list of entities.

An Entity is any type that inherits the root Entity interface:

 

export type Entity = {

  id: string;

  assigned_user_id: string;

  source: string;

  label: string;

  tags: string[];

  starred: boolean;

  description: string;

  createdtime: string;

  modifiedtime: string;

};

 

Our task is to create a component that would standardize the output of the list of entities. So, our main component, we create the src/features/misc/components/ListEntities.tsx file with the following contents:

 

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>

  );

};

 

In this case, our component takes 3 parameters:

  • entities - array of elements to display, they must inherit Entity type.
  • headerExtractor - function that returns us a value to display header in the table.
  • statusExtractor - allows us to display status in the table.
  • renderItem - an optional function that provides JSX for the list of items.

As you can notice the component has no state, so it's very easy to test it:

 

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();

  });

});

 

So, now we can safely create a ProjectTasks component that will use ListEntities to display a list of project tasks. Let's create the src/features/task/components/ProjectTasks.tsx file with the following contents:

 

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>

  );

};

 

An array of entities entities comes to us from the API via ReactQuery. Before displaying the list, we check if it is empty or if it is being loaded. If it is, we display an appropriate message. If the array is not empty, we use our ListEntities component and pass the project tasks to it. As parameter headerExtractor and statusExtractor we pass the corresponding fields of project task. And finally, we pass the layout of the project task card with our custom styles to the renderEntity function. Keep in mind that you can skip this function, in which case we'll get a default layout.

 

Render Props is a really useful pattern that can be used to create flexible components that allow developers to override some or all of the component content. Note that you don't always need to require that a component be provided with JSX via parameters, because if it isn't, you can always generate default content.

Popular Posts

My most popular posts

Maximum productivity on remote job
Business

Maximum productivity on remote job

I started my own business and intentionally did my best to work from anywhere in the world. Sometimes I sit with my office with a large 27-inch monitor in my apartment in Cheboksary. Sometimes I’m in the office or in some cafe in another city.

Hello! I am Sergey Emelyanov and I am hardworker
Business PHP

Hello! I am Sergey Emelyanov and I am hardworker

I am a programmer. I am an entrepreneur in my heart. I started making money from the age of 11, in the harsh 90s, handing over glassware to a local store and exchanging it for sweets. I earned so much that was enough for various snacks.

Hire Professional CRM developer for $25 per hour

I will make time for your project. Knowledge of Vtiger CRM, SuiteCRM, Laravel, and Vue.js. I offer cooperation options that will help you take advantage of external experience, optimize costs and reduce risks. Full transparency of all stages of work and accounting for time costs. Pay only development working hours after accepting the task. Accept PayPal and Payoneer payment systems. How to hire professional developer? Just fill in the form

Telegram
@sergeyem
Telephone
+4915211100235