React Hooks: контекст, состояние и эффекты

Пример из практики

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

Моя текущая команда работает над исправлением этого на нашем сайте. Вместо того, чтобы делать настройки для конкретного пользователя (например, отображать изображение профиля) на сервере, мы планируем делать эти настройки на стороне клиента с помощью JavaScript. Это позволит нам обслуживать одни и те же статические страницы через CDN для всех пользователей. После загрузки этих страниц клиентский JavaScript может запросить сервер, чтобы узнать, вошел ли пользователь в систему и (если да) какой URL-адрес его фотографии профиля. Когда приходит ответ на этот запрос, клиентский JavaScript может либо вставить фотографию пользователя на страницу, либо вставить ссылку «Войти», если пользователь не вошел в систему.

Есть несколько различных способов сделать такую ​​настройку на стороне клиента. Мы выбрали фреймворк React из-за его повсеместности (мы уверены, что это упростит поиск подрядчика, который поможет нам в нашей работе) и его надежной и относительно зрелой экосистемы инструментов.

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

Введение в хуки

Компоненты Simple React могут быть написаны как функции, которые принимают объект свойств в качестве входных данных и возвращают строку или элемент JSX для визуализации. Например:

function Link({href, children}) {
 return <a href={href}>{children}</a>;
}

Однако до появления хуков в React 16.8 функциональные компоненты были весьма ограничены: они не могли определять локальные переменные состояния и не могли выполнять побочные эффекты при первом рендеринге (или «монтировании») или при повторном рендеринге. Чтобы создать компоненты, которые могли бы делать эти вещи, нам пришлось определить их как классы, унаследованные от React.Component. Метод render () такого класса будет принимать объект свойств в качестве входных данных и возвращать содержимое для визуализации. В дополнение, однако, этот render() метод может использовать свойства объекта this.state для настройки своего вывода. Компоненты React, определенные как классы, наследуют метод setState() суперкласса. Если метод render() выводит элементы HTML с обработчиками событий, один из этих обработчиков может вызвать setState(), чтобы изменить объект this.state и вызвать повторную визуализацию компонента в его новом состоянии. Наконец, компоненты на основе классов могут определять «методы жизненного цикла», такие как componentDidMount() (вызываются React после первого рендеринга) и componentDidUpdate() (вызываются React после последующих рендеров). Метод componentDidMount() может использоваться, например, для регистрации глобальных обработчиков событий или для запроса данных с помощью вызова fetch().

В React 16.8 хуки позволяют нам использовать состояние и определять эффекты жизненного цикла в функциональных компонентах React. Хуки - это просто функции, импортированные из модуля React, которые вы вызываете в своем компоненте. По соглашению, имена хуков начинаются с use: в этом сообщении блога мы опишем хуки useContext(), useState() и useEffect(). Также существуют важные соглашения (в документации React они называются «Правилами хуков»), которые определяют, как вы можете вызывать хуки: вы можете использовать их только в функциональных компонентах React, а не в компонентах на основе классов. И они должны вызываться на верхнем уровне ваших компонентных функций: вы не можете использовать их в условных выражениях, циклах или вложенных функциях.

Идея о том, что вы можете вызвать функцию useState(), чтобы каким-то образом ввести переменные локального состояния в свой функциональный компонент, глубоко противоречит здравому смыслу, и React должен тратить некоторую магию за кулисами, чтобы заставить ее работать. Обычно я не одобряю API, которые полагаются на закулисную магию, но должен признать, что, как только я к ним привык, я обнаружил, что на практике ловушки довольно удобны. Итак, давайте заглянем за занавес, чтобы развить немного интуиции о том, как работают хуки: важно понять, что когда вы определяете функциональный компонент React, ваша функция будет вызываться только очень специфическим и контролируемым образом, когда React создает или обновление его дерева компонентов. Таким образом, даже если у функционального компонента нет связанного экземпляра, как у компонента на основе классов, каждый вызов вашего функционального компонента, по сути, связан с определенным узлом в дереве, которое строит React. Хуки дают нам возможность связать вещи (например, контекст, состояние и эффекты) с узлами во внутренней структуре данных React.

Это сложное объяснение, но, надеюсь, это достаточно правдоподобное объяснение того, почему крючки работают. В компонентах на основе классов каждый экземпляр компонента представляет собой свою собственную частную структуру данных в более крупном дереве, которое строит React. С функциональными компонентами хуки позволяют нам вставлять данные - очень специфическими и контролируемыми способами - непосредственно в это дерево, и экземпляры компонентов больше не нужны. Если вы найдете это объяснение достаточно убедительным, то нет необходимости более глубоко думать о том, как работают перехватчики, и я рекомендую вам просто воспользоваться магией этих специальных функций с префиксом use.

Хуки лучше всего объяснить на примере, поэтому мы вернемся к моему желанию отображать изображения профиля пользователя на стороне клиента, а не на стороне сервера. В следующих разделах представлены крючки useContext(), useState() и useEffect().

useContext ()

Контекстный механизм React - это способ сделать важные данные доступными для любого компонента в отображаемом дереве без необходимости передавать эти данные от родителя к потомку по всему дереву в качестве свойства. Канонический вариант использования контекстов - сделать данные темы UX доступными для всех компонентов в дереве. Для работы с изображением профиля я решил сделать данные пользователя (включая URL изображения профиля) доступными через механизм контекста React, потому что я ожидаю, что мы добавим больше пользовательских данных, на которых будут основываться настройки. Если в будущем мы разрешим пользователям указывать предпочтительную тему, например, тогда многим компонентам может потребоваться знать об этом предпочтении пользователя.

Работа с контекстами в React включает использование компонента «Provider» контекста наверху в дереве компонентов и передачу данных контекста этому компоненту в качестве опоры. Когда компоненту React на основе классов требуется доступ к данным контекста, он отображает компонент «Потребитель» контекста с функцией в качестве его единственного дочернего элемента. React вызывает эту функцию с данными контекста в качестве аргумента, а затем эта функция ведет себя как метод рендеринга, возвращающий элементы для отображения. Это неудобное косвенное обращение, из-за которого механизм контекста React всегда казался сложным. К счастью, хуки избавляют от необходимости отображать потребителя контекста и упрощают создание компонентов, использующих данные контекста. В качестве примера, вот упрощенная версия моего компонента входа в систему, который использует механизм контекста для получения данных о текущем пользователе:

import { useContext } from 'react';   // Import the useContext hook
// Import the module that defines the user context
import UserProvider from './user-provider.jsx';
export default function Login() {
  // Get data about the current user by calling useContext()
  const userData = useContext(UserProvider.context);
  if (!userData) {
    // If we don't have the user data yet, don't render anything
    return null;
  } else if (userData.isAuthenticated) {
    // If we have user data and the user is logged in, render      
    // the user's profile pic
    return (
      <img width=48 height=48
        src={userData.gravatarUrl.small}
        alt={userData.username}
      />
    );
  } else {
    // Otherwise, show a login prompt
    return (
      <a href={`/users/github/login/?next=${
                  window.location.pathname
                }`}>
        Sign in
      </a>
    );
  }
}

Строка 1 этого кода импортирует ловушку useContext() из модуля React. Строка 4 импортирует модуль UserProvider, который определяет объект контекста, который мы хотим использовать. (Мы увидим код этого модуля позже в этом сообщении блога). В строке 8 вызывается ловушка useContext(), передающая объект UserProvider.context, чтобы указать, какой контекст мы хотим использовать. Возвращаемое значение этого useContext() вызова - это те данные, которые предоставляет поставщик контекста.

Остальная часть листинга кода показывает, как этот (упрощенный) компонент входа в систему использует данные контекста. Возможны три случая. В первом случае данные о пользователях вообще отсутствуют, вероятно, потому, что они еще не были получены. В этом случае функция Login() возвращает null, ничего не отображая. В противном случае, если объект userData не равен нулю и показывает, что пользователь вошел в систему, то компонент отображает изображение профиля пользователя. И, наконец, если у нас есть userData, но они показывают, что пользователь не вошел в систему, тогда компонент отображает ссылку «Войти».

Как видите, ловушка useContext() упрощает для моего компонента входа в систему доступ и использование информации о текущем пользователе. Но откуда берутся эти контекстные данные? Вот отрывок из файла index.jsx верхнего уровня, который запускает процесс рендеринга React:

ReactDOM.render(
  <DocumentProvider>
    <UserProvider>
      <Page />
    </UserProvider>
  </DocumentProvider>,
  document.getElementById('react-container')
);

В этой версии сайта на основе React, над которой я работаю, страница отображается компонентом Page внутри компонента UserProvider, который сам находится внутри компонента DocumentProvider. Компонент UserProvider отвечает за настройку объекта контекста пользователя и предоставление для этого соответствующих данных. (DocumentProvider делает нечто подобное для содержимого страницы, а также отвечает за навигацию на стороне клиента между страницами на сайте, но это тема для другого сообщения в блоге.)

Итак, когда страница отображается, компонент UserProvider устанавливает объект контекста для обмена данными о пользователе и предоставляет эти данные. Затем он отображает свой дочерний компонент, компонент Page. Компонент Page отображает компонент Header (который мы здесь не обсуждали), который, в свою очередь, отображает компонент Login. Компонент входа в систему использует ловушку useContext() для получения доступа к данным контекста, определенным контекстом UserProvider.

Вот очень упрощенная версия реализации компонента UserProvider:

import { createContext } from 'react';
const context = createContext(null);
export default function UserProvider({ children }) {
  return (
    <context.Provider value={null}>
      {children}
    </context.Provider>
  );
}
UserProvider.context = context;

Этот код импортирует функцию createContext() React и использует эту функцию для создания объекта контекста для совместного использования контекста пользователя с другими компонентами. Затем он определяет экспортированный компонент UserProvider и прикрепляет объект контекста к компоненту UserProvider, чтобы он тоже был экспортирован. Принцип работы API контекста React заключается в том, что каждый объект контекста имеет свойство Provider, значение которого является компонентом React. Чтобы установить значение контекста, вы визуализируете этот <context.Provider> компонент, устанавливая его value опору на данные контекста, которые будут совместно использоваться с потребителями. Компонент UserProvider, определенный выше, пытается скрыть часть этой сложности: он отображает компонент context.Provider, жестко кодирует значение null, а затем отображает своих собственных дочерних элементов внутри этого поставщика.

Я показал этот код здесь, чтобы продемонстрировать, как работают поставщики контекста React. Однако обратите внимание, что эта версия UserProvider совершенно бесполезна, потому что она всегда предоставляет значение null. Чтобы действительно предоставлять значимые данные, нам нужно ввести ловушки useState() и useEffect().

useState ()

Компоненты React используют состояние, когда они хотят изменить свой внешний вид на основе не переданных им свойств, а на основе того, что с ними произошло. Например, если вы реализуете раскрывающееся меню с помощью React, вы можете использовать переменную состояния, чтобы отслеживать, открыто или закрыто меню. Код рендеринга компонента будет написан для рендеринга меню в его открытом или закрытом состоянии на основе значения переменной состояния. И отображаемое меню будет иметь обработчики событий мыши, которые будут устанавливать переменную состояния в зависимости от того, где и когда пользователь щелкнул мышью. Одна из ключевых вещей, которые нужно понять о состоянии в компонентах React, заключается в том, что при изменении состояния React повторно отображает компонент. Таким образом, в компоненте раскрывающегося меню щелчок мыши может привести к изменению переменной состояния open компонента с false на true. Это изменение состояния вызывает повторное отображение меню. Код рендеринга смотрит на значение этой open переменной и рендерит открытую форму меню вместо закрытой формы меню.

Чаще всего, когда компонент React использует состояние, изменения состояния вызываются событиями мыши или клавиатуры. Но состояние также может меняться в зависимости от таймера или сетевых событий. Для компонента UserProvider нас интересуют сетевые события. При первой загрузке страницы UserProvider будет в исходном состоянии: у него не будет данных о пользователе, а значение контекста будет просто null. Но мы попросим сервер сообщить нам о текущем пользователе (что он может сделать на основе файла cookie сеанса пользователя). И когда мы получим ответ на этот запрос, UserProvider будет в новом состоянии: состоянии, в котором он знает о пользователе.

Вот версия компонента UserProvider, который использует useState() hook для определения своего состояния и управления им:

import { createContext, useState } from 'react';
const context = React.createContext(null);
export default function UserProvider({ children }) {
  const [userData, setUserData] = useState(null);
  return (
    <context.Provider value={userData}>
      {children}
    </context.Provider>
  );
}
UserProvider.context = context;

Эта версия UserProvider импортирует ловушку useState(), а затем вызывает ее для определения переменной состояния. Аргумент useState() (в данном случае null) - это начальное значение состояния. Возвращаемое значение useState() - это массив из двух элементов. Первый - это текущее значение переменной состояния, а второй - функция, которую вы можете использовать для установки нового значения для переменной. Принято использовать деструктурирующее присваивание совпадающей паре имен (например, userData и setUserData), как мы это сделали здесь.

Наш вызов useState() возвращает текущее значение переменной состояния и сохраняет его в константе userData. В следующей строке мы используем userData в качестве значения компонента поставщика контекста. Это означает, что любые дочерние компоненты (например, наш компонент входа в систему), вызывающие useContext(UserProvider.context), будут иметь доступ к значению этой переменной состояния userData.

Когда этот компонент UserProvider впервые отображается, useState() вызывается в первый раз, а его аргумент null используется как значение начального состояния и как первый элемент возвращаемого массива. Если бы setUserData() был вызван, аргумент этой функции стал бы новым значением переменной состояния. Изменение состояния приведет к повторной визуализации компонента. В этом новом рендере useState() вызывается второй раз. Но на этот раз аргумент null игнорируется, и новое текущее значение возвращается как первый элемент массива. (Как отмечалось выше во введении к крючкам, это волшебная и неинтуитивная часть хуков. Если вы сейчас в замешательстве, возможно, вам стоит перечитать этот раздел.)

В нашем случае нет ничего, что бы когда-либо вызывало setUserData(), поэтому состояние никогда не меняется, и этот UserProvider всегда предоставляет одно и то же бесполезное значение null. Нам нужно запросить у сервера информацию о пользователе, и для этого нам понадобится хук useEffect().

useEffect ()

Хук useEffect() принимает функцию в качестве первого аргумента и по умолчанию запускает эту функцию после каждого рендеринга. Используемый с одним таким аргументом, он аналогичен компоненту на основе классов, который определяет методы componentDidMount() и componentDidUpdate(). Если эффект, который вы хотите достичь, представляет собой что-то вроде регистрации глобального обработчика событий, вам может потребоваться код очистки для удаления обработчика событий, когда он больше не нужен. Если ваша функция эффекта возвращает функцию, React запомнит эту функцию и вызовет ее перед следующим вызовом функции эффекта, а также при размонтировании компонента. Я не буду использовать эту функцию в этом сообщении в блоге, но стоит помнить, что эффекты, зарегистрированные с помощью useEffect(), имеют параметр очистки.

Для нашего компонента UserProvider мы хотим получить данные пользователя с сервера. Но нам нужно сделать это только при первом рендере. Мы не ожидаем изменения данных, поэтому не хотим получать их повторно при последующих рендерингах. useEffect() принимает массив как необязательный второй аргумент. Если вы передаете массив, то значения в нем используются как своего рода ключ кеша, а ваша функция эффекта вызывается только при первом рендеринге или при изменении значений в массиве.

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

Обратите внимание, что это означает, что если вы передадите неизменный массив констант в качестве второго аргумента, тогда функция эффекта будет вызвана только один раз, и мы получим нечто похожее на componentDidMount() для компонентов на основе классов. Если это то, что вы хотите, нормально передать пустой массив в качестве второго аргумента, и это то, что вы увидите в обновленной версии компонента UserProvider ниже:

import { createContext, useState, useEffect } from 'react';
const context = createContext(null);
export default function UserProvider({ children }) {
  // The useState() hook defines a state variable.
  const [userData, setUserData] = useState(null);
  // The useEffect() hook registers a function to run after render.
  useEffect(() => {
    fetch('/api/v1/whoami')        // Ask the server for user data.
      .then(response => response.json()) // Get the response as JSON
      .then(data => {                    // When data arrives...
        setUserData({                    // set our state variable.
          username: data.username,
          isAuthenticated: data.is_authenticated,
          timezone: data.timezone,
          gravatarUrl: data.gravatar_url
        });
      });
  }, []);  // This empty array means the effect will only run once.
  // On the first render userData will have the default value null.
  // But after that render, the effect function will run and will
  // start a fetch of the real user data. When the data arrives, it
  // will be passed to setUserData(), which changes state and
  // triggers a new render. On this second render, we'll have real
  // user data to provide to any consumers. (And the effect will not
  // run again.)
  return (
    <context.Provider value={userData}>
      {children}
    </context.Provider>
  );
}
UserProvider.context = context;

В этой последней версии UserProvider ловушки useEffect() и useState() переплетаются: ловушка useEffect() запускает сетевой запрос для пользовательских данных после первого рендеринга, и когда эти данные поступают, функция эффекта передает их в функцию setUserData(), которая была возвращена функцией useState() крючок. Это изменение переменной состояния вызывает повторную визуализацию компонента, которая обновляет поставщик контекста вновь полученными данными пользователя. А это означает, что такие функции, как компонент входа в систему в другом месте дерева, могут получить доступ к этим данным с помощью ловушки useContext().

Дальнейшее чтение

Компоненты Login и UserProvider, которые я здесь описал, доставляли удовольствие, и было особенно приятно видеть, как useContext(), useState() и useEffect() объединились, чтобы заставить их работать. Если вы разработчик React, надеюсь, я передал свой энтузиазм по поводу нового API хуков. Если да, то вот несколько способов узнать больше:

  • Если вы хотите поближе познакомиться с (неупрощенными версиями) Login и UserProvider и связанными с ними компонентами, вы можете найти их на GitHub. (Эта ссылка действительна на момент написания этой статьи, хотя UserProvider на самом деле называется CurrentUser. Наш код на основе React является экспериментальным и быстро меняется, поэтому вполне вероятно, что код будет перемещен, и эта ссылка быстро устареет.)
  • Ключевой особенностью компонента UserProvider, описанного в этом посте, является то, что он использует клиентский JavaScript для динамического запроса данных с веб-сервера. Если вы еще не знакомы с функцией fetch(), используемой для выполнения сетевого запроса, вы можете найти исчерпывающую документацию по MDN.
  • Наконец, если вы хотите узнать больше о хуках в React, документация из проекта React вполне подойдет.