Рендер-пропсы

Термин «рендер-проп» относится к возможности компонентов React разделять код между собой с помощью пропа, значение которого является функцией.

Компонент с рендер-пропом берёт функцию, которая возвращает React-элемент, и вызывает её вместо реализации собственного рендера.

<DataProvider render={data => (
  <h1>Привет, {data.target}</h1>
)}/>

Такой подход, в частности, применяется в библиотеках React Router, Downshift и Formik.

В этой статье мы покажем, чем полезны и как писать рендер-пропсы.

Использование рендер-пропа для сквозных задач

Компоненты — это основа повторного использования кода в React. Однако бывает неочевидно, как сделать, чтобы одни компоненты разделяли своё инкапсулированное состояние или поведение с другими компонентами, заинтересованными в таком же состоянии или поведении.

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

class MouseTracker extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>
        <h1>Перемещайте курсор мыши!</h1>
        <p>Текущее положение курсора мыши: ({this.state.x}, {this.state.y})</p>
      </div>
    );
  }
}

Когда курсор перемещается по экрану, компонент отображает координаты (x, y) внутри <p>.

Возникает вопрос: как мы можем повторно использовать это поведение в другом компоненте? То есть если другому компоненту необходимо знать о позиции курсора, можем ли мы как-то инкапсулировать это поведение, чтобы затем легко использовать его в этом компоненте?

Поскольку компоненты являются основой повторного использования кода в React, давайте применим небольшой рефакторинг. Пусть наш код полагается на компонент <Mouse>, инкапсулирующий поведение, которое мы хотим применять в разных местах.

// Компонент <Mouse> инкапсулирует поведение, которое нам необходимо...
class Mouse extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>

        {/* ...но как можно отрендерить что-то, кроме <p>? */}
        <p>Текущее положение курсора мыши: ({this.state.x}, {this.state.y})</p>
      </div>
    );
  }
}

class MouseTracker extends React.Component {
  render() {
    return (
      <>
        <h1>Перемещайте курсор мыши!</h1>
        <Mouse />
      </>
    );
  }
}

Теперь компонент <Mouse> инкапсулирует всё поведение, связанное с обработкой событий mousemove и хранением позиций курсора (x, y), но пока не обеспечивает повторного использования.

Например, допустим у нас есть компонент <Cat>, который рендерит изображение кошки, преследующей мышь по экрану. Мы можем использовать проп <Cat mouse={{ x, y }}>, чтобы сообщить компоненту координаты мыши, и он знал, где расположить изображение на экране.

Для начала вы можете отрендерить <Cat> внутри метода render компонента <Mouse> следующим образом:

class Cat extends React.Component {
  render() {
    const mouse = this.props.mouse;
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
    );
  }
}

class MouseWithCat extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>

        {/*
          Мы могли бы просто поменять <p> на <Cat>... но тогда
          нам нужно создать отдельный компонент <MouseWithSomethingElse>
          каждый раз, когда он нужен нам, поэтому <MouseWithCat>
          пока что нельзя повторно использовать.
        */}
        <Cat mouse={this.state} />
      </div>
    );
  }
}

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Перемещайте курсор мыши!</h1>
        <MouseWithCat />
      </div>
    );
  }
}

Этот подход будет работать для конкретного случая, но мы не достигли основной цели — инкапсулировать поведение с возможностью повторного использования. Теперь, каждый раз когда мы хотим получить позицию мыши для разных случаев, нам требуется создавать новый компонент (т. е. другой экземпляр <MouseWithCat>), который рендерит что-то специально для этого случая.

Вот здесь рендер-проп нам и понадобится: вместо явного указания <Cat> внутри <Mouse> компонента, и трудозатратных изменений на выводе рендера, мы предоставляем <Mouse> функцию в качестве пропа, с которой мы используем динамическое определение того, что нужно передавать в рендер-проп.

class Cat extends React.Component {
  render() {
    const mouse = this.props.mouse;
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
    );
  }
}

class Mouse extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100vh' }} onMouseMove={this.handleMouseMove}>

        {/*
          Вместо статического представления того, что рендерит <Mouse>,
          используем рендер-проп для динамического определения, что надо отрендерить.
        */}
        {this.props.render(this.state)}
      </div>
    );
  }
}

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Перемещайте курсор мыши!</h1>
        <Mouse render={mouse => (
          <Cat mouse={mouse} />
        )}/>
      </div>
    );
  }
}

Теперь, вместо того, чтобы фактически клонировать компонент <Mouse> и жёстко указывать что-нибудь ещё в методе render, для решения специфичного случая, мы предоставляем рендер-проп компоненту <Mouse>, который может динамически определить что рендерить.

Иными словами, рендер-проп — функция, которая сообщает компоненту что необходимо рендерить.

Эта техника позволяет сделать легко портируемым поведение, которое мы хотим повторно использовать. Для этого следует отрендерить компонент <Mouse> с помощью рендер-пропа, который сообщит, где отрендерить курсор с текущим положением (x, y).

Один интересный момент касательно рендер-пропсов заключается в том, что вы можете реализовать большинство компонентов высшего порядка (HOC), используя обычный компонент вместе с рендер-пропом. Например, если для вас предпочтительней HOC withMouse вместо компонента <Mouse>, вы можете создать обычный компонент <Mouse> вместе с рендер-пропом:

// Если вам действительно необходим HOC по некоторым причинам, вы можете просто
// создать обычный компонент с рендер-пропом!
function withMouse(Component) {
  return class extends React.Component {
    render() {
      return (
        <Mouse render={mouse => (
          <Component {...this.props} mouse={mouse} />
        )}/>
      );
    }
  }
}

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

Использование пропсов, отличных от render (как название передаваемого свойства)

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

Несмотря на то, что в вышеприведённых примерах мы используем render, мы можем также легко использовать проп children!

<Mouse children={mouse => (
  <p>Текущее положение курсора мыши: {mouse.x}, {mouse.y}</p>
)}/>

И запомните, проп children не обязательно именовать в списке «атрибутов» вашего JSX-элемента. Вместо этого, вы можете поместить его прямо внутрь элемента!

<Mouse>
  {mouse => (
    <p>Текущее положение курсора мыши: {mouse.x}, {mouse.y}</p>
  )}
</Mouse>

Эту технику можно увидеть в действии в API библиотеки react-motion.

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

Mouse.propTypes = {
  children: PropTypes.func.isRequired
};

Предостережения

Будьте осторожны при использовании рендер-проп вместе с React.PureComponent

Использование рендер-пропа может свести на нет преимущество, которое даёт React.PureComponent, если вы создаёте функцию внутри метода render. Это связано с тем, что поверхностное сравнение пропсов всегда будет возвращать false для новых пропсов и каждый render будет генерировать новое значение для рендер-пропа.

Например, в продолжение нашего <Mouse> компонента упомянутого выше, если Mouse наследуется от React.PureComponent вместо React.Component, наш пример будет выглядеть следующим образом:

class Mouse extends React.PureComponent {
  // Та же реализация, что и упомянутая выше...
}

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Перемещайте курсор мыши!</h1>

        {/*
          Это плохо! Значение рендер-пропа будет
          разным при каждом рендере.
        */}
        <Mouse render={mouse => (
          <Cat mouse={mouse} />
        )}/>
      </div>
    );
  }
}

В этом примере, при каждом рендере <MouseTracker> генерируется новая функция в качестве значения пропа <Mouse render>. Это сводит на нет эффекты React.PureComponent, от которого наследует <Mouse>!

Чтобы решить эту проблему, вы можете определить проп как метод экземпляра, например так:

class MouseTracker extends React.Component {
  // Определяем как метод экземпляра, `this.renderTheCat` всегда
  // ссылается на *ту же самую* функцию, когда мы используем её в рендере
  renderTheCat(mouse) {
    return <Cat mouse={mouse} />;
  }

  render() {
    return (
      <div>
        <h1>Перемещайте курсор мыши!</h1>
        <Mouse render={this.renderTheCat} />
      </div>
    );
  }
}

В случаях, когда вы не можете определить проп статически (например, вам необходимо замкнуть пропсы и/или состояние компонента), <Mouse> нужно наследовать от React.Component.