Запрос к API c React Hooks, HOC или Render Prop

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.


Рассмотрим реализацию запроса данных к API c помощью нового друга React Hooks и старых добрых товарищей Render Prop и HOC (Higher Order Component). Выясним, действительно ли новый друг лучше старых двух.


Жизнь не стоит на месте, React меняется в лучшую сторону. В феврале 2019 года в React 16.8.0 появились React Hooks. Теперь в функциональных компонентах можно работать с локальным состоянием и выполнять сайд-эффекты. Никто не верил, что это возможно, но все всегда это хотели. Если вы еще не в курсе деталей, за подробностями сюда.


React Hooks дают возможность наконец-то отказаться от таких паттернов как HOC и Render Prop. Потому что за время использования к ним накопилось ряд претензий:


RProp HOC
1. Много компонентов-оберток, в которых сложно разобраться в React DevTools и в коде. (◕︵◕) (◕︵◕)
2. Сложно типизировать (Flow, TypeScript). (◕︵◕)
3. Не очевидно, от какого HOC какие props компонент получает, что усложняет процесс дебаггинга и понимание как работает компонент. (◕︵◕)
4. Render Prop чаще всего не добавляет верстки, хотя используется внутри JSX. (◕︵◕)
5. Коллизия ключей props. При передаче props от родителей одинаковые ключи могут быть перезаписаны значениями из HOC. (◕︵◕)
6. Сложно читать git diff, так как смещаются все отступы в JSX при оборачивании JSX в Render Prop. (◕︵◕)
7. Если несколько HOC, то можно ошибиться с последовательностью композиции. Правильный порядок не всегда очевиден, так как логика спрятана внутри HOC. Например, когда сначала проверяем авторизован ли пользователь, и только потом запрашиваем личные данные. (◕︵◕)

Чтобы не быть голословной, давайте рассмотрим на примере чем React Hooks лучше (а может все-таки хуже) Render Prop. Будем рассматривать Render Prop, а не HOC, так как в реализации они очень похожи и у HOC больше недостатков. Попробуем написать утилиту, которая обрабатывает запрос данных к API. Я уверена, что многие писали это в своей жизни сотни раз, ну что же посмотрим можно ли еще лучше и проще.


Для этого будем использовать популярную библиотеку axios. В самом простом сценарии нужно обработать следующие состояния:


  • процесс получения данных (isFetching)
  • данные успешно получены (responseData)
  • ошибка получения данных (error)
  • отмена запроса, если в процессе его выполнения поменялись параметры запроса, и нужно отправить новый
  • отмена запроса, если данного компонента больше нет в DOM

1. Простой сценарий


Напишем дефолтный state и функцию (reducer), которая меняет state в зависимости от результата запроса: success / error.


Что такое Reducer?

Справочно. Reducer к нам пришел из функционального программирования, а для большинства JS разработчиков из Redux. Это функция, которая принимает предыдущее состояние и действие (action) и возвращает следующее состояние.


const defaultState = {
  responseData: null,
  isFetching: true,
  error: null
};

function reducer1(state, action) {
  switch (action.type) {
    case "fetched":
      return {
        ...state,
        isFetching: false,
        responseData: action.payload
      };
    case "error":
      return {
        ...state,
        isFetching: false,
        error: action.payload
      };
    default:
      return state;
  }
}

Эту функцию мы переиспользуем в двух подходах.


Render Prop


class RenderProp1 extends React.Component {
  state = defaultState;

  axiosSource = null;

  tryToCancel() {
    if (this.axiosSource) {
      this.axiosSource.cancel();
    }
  }

  dispatch(action) {
    this.setState(prevState => reducer(prevState, action));
  }

  fetch = () => {
    this.tryToCancel();
    this.axiosSource = axios.CancelToken.source();
    axios
      .get(this.props.url, {
        cancelToken: this.axiosSource.token
      })
      .then(response => {
        this.dispatch({ type: "fetched", payload: response.data });
      })
      .catch(error => {
        this.dispatch({ type: "error", payload: error });
      });
  };

  componentDidMount() {
    this.fetch();
  }

  componentDidUpdate(prevProps) {
    if (prevProps.url !== this.props.url) {
      this.fetch();
    }
  }

  componentWillUnmount() {
    this.tryToCancel();
  }

  render() {
    return this.props.children(this.state);
  }

React Hooks


const useRequest1 = url => {
  const [state, dispatch] = React.useReducer(reducer, defaultState);

  React.useEffect(() => {
    const source = axios.CancelToken.source();
    axios
      .get(url, {
        cancelToken: source.token
      })
      .then(response => {
        dispatch({ type: "fetched", payload: response.data });
      })
      .catch(error => {
        dispatch({ type: "error", payload: error });
      });
    return source.cancel;
  }, [url]);

  return [state];
};

По url из используемого компонента получаем данные — axios.get(). Обрабатываем success и error, меняя state через dispatch(action). Возвращаем state в компонент. И не забываем отменить запрос в случае изменения url или если компонент удалился из DOM. Все просто, но написать можно по-разному. Выделим плюсы и минусы у двух подходов:


Hooks RProp
1. Меньше кода. (◑‿◐)
2. Вызов сайд-эффекта (запрос данных в API) читается проще, так как написан линейно, не размазан по жизненным циклам компонента. (◑‿◐)
3. Отмена запроса написана сразу после вызова запроса. Все в одном месте. (◑‿◐)
4. Простой код, описывающий отслеживание параметров для вызова сайд-эффектов. (◑‿◐)
5. Очевидно, в какой цикл жизни компонента будет выполнен наш код. (◑‿◐)

React Hooks позволяют писать меньше кода, и это неоспоримый факт. Значит, эффективность вас как разработчика растет. Но придется освоить новую парадигму.


Когда есть названия циклов жизни компонента все очень понятно. Сначала мы получаем данные после того, как компонент появился на экране (componentDidMount), потом повторно получаем, если поменялся props.url и перед этим руками не забыть отменить предыдущий запрос (componentDidUpdate), если компонент удалился из DOM, то отменяем запрос (componentWillUnmount).


Но теперь мы вызываем сайд-эффект прям в рендере, нас же учили, что так нельзя. Хотя стоп, не совсем в рендере. А внутри функции useEffect, которая будет выполнять асинхронно что-то после каждого рендера, а точнее коммита и отрисовки нового DOM.


Но нам не надо после каждого рендера, а надо только на первый рендер и в случае изменения url, что мы указываем вторым аргументом в useEffect.


Новая парадигма

Понимание как работают React Hooks требует осознание новых вещей. Например, разницу между фазами: коммит и рендер. В фазе рендера React вычисляет, какие изменения надо применить в DOM, путем сравнения с результатом предыдущего рендера. А в фазе коммита React применяет данные изменения в DOM. Именно в фазе коммита вызываются методы: componentDidMount и componentDidUpdate. А вот то, что написано в useEffect, будет вызвано после коммита асинхронно и, таким образом, не будет блокировать отрисовку DOM, если вы вдруг случайно решили что-то синхронно много посчитать в сайд-эффекте.


Вывод — используйте useEffect. Писать меньше и безопаснее.


И еще одна прекрасная фича: useEffect умеет подчищать за предыдущим эффектом и после удаления компонента из DOM. Спасибо Rx, которые вдохновили команду React на такой подход.


Использование нашей утилиты с React Hooks тоже намного удобнее.


const AvatarRenderProp1 = ({ username }) => (
  <RenderProp url={`https://api.github.com/users/${username}`}>
    {state => {
      if (state.isFetching) {
        return "Loading";
      }

      if (state.error) {
        return "Error";
      }

      return <img src={state.responseData.avatar_url} alt="avatar" />;
    }}
  </RenderProp>
);

const AvatarWithHook1 = ({ username }) => {
  const [state] = useRequest(`https://api.github.com/users/${username}`);

  if (state.isFetching) {
    return "Loading";
  }

  if (state.error) {
    return "Error";
  }

  return <img src={state.responseData.avatar_url} alt="avatar" />;
};

Вариант с React Hooks опять выглядит более компактным и очевидным.


Минусы Render Prop:


1) непонятно добавляется ли верстка или только логика
2) если надо будет состояние из Render Prop обработать в локальном state или жизненных циклах дочернего компонента придется создать новый компонент


Добавим новый функционал — получение данных с новыми параметрами по действию пользователя. Захотелось, например, кнопку, которая получает аватарку вашего любимого разработчика.


2) Обновлению данных по действию пользователя


Добавим кнопку, которая отправляет запрос с новым username. Самое простое решение — это хранить username в локальном state компонента и передавать новый username из state, а не props как сейчас. Но тогда нам придется copy-paste везде, где понадобится похожий функционал. Так что вынесем этот функционал в нашу утилиту.


Использовать будем так:


const Avatar2 = ({ username }) => {
 ...
     <button
       onClick={() => update("https://api.github.com/users/NewUsername")}
     >
        Update avatar for New Username
     </button>

 ...
};

Давайте писать реализацию. Ниже написаны только изменения по сравнению с первоначальным вариантом.


function reducer2(state, action) {
  switch (action.type) {
   ...
   case "update url":
      return {
        ...state,
        isFetching: true,
        url: action.payload,
        defaultUrl: action.payload
      };
    case "update url manually":
      return {
        ...state,
        isFetching: true,
        url: action.payload,
        defaultUrl: state.defaultUrl
      };
   ...
  }
}

Render Prop


class RenderProp2 extends React.Component {
  state = {
    responseData: null,
    url: this.props.url,
    defaultUrl: this.props.url,
    isFetching: true,
    error: null
  };

  static getDerivedStateFromProps(props, state) {
    if (state.defaultUrl !== props.url) {
      return reducer(state, { type: "update url", payload: props.url });
    }
    return null;
  }

 ...

 componentDidUpdate(prevProps, prevState) {
   if (prevState.url !== this.state.url) {
     this.fetch();
   }
 }

 ...

 update = url => {
   this.dispatch({ type: "update url manually", payload: url });
 };

 render() {
   return this.props.children(this.state, this.update);
 }
}

React Hooks


const useRequest2 = url => {
 const [state, dispatch] = React.useReducer(reducer, {
    url,
    defaultUrl: url,
    responseData: null,
    isFetching: true,
    error: null
  });

 if (url !== state.defaultUrl) {
    dispatch({ type: "update url", payload: url });
  }

 React.useEffect(() => {
   …(fetch data);
 }, [state.url]);

 const update = React.useCallback(
   url => {
     dispatch({ type: "update url manually", payload: url });
   },
   [dispatch]
 );

 return [state, update];
};

Если вы внимательно посмотрели код, то заметили:


  • url стали сохранять внутри нашей утилиты;
  • появился defaultUrl для идентификации, что url обновился через props. Нам нужно следить за изменением props.url, иначе новый запрос не отправится;
  • добавили функцию update, которую возвращаем в компонент для отправки нового запроса по клику на кнопку.

Обратите внимание с Render Prop нам пришлось воспользоваться getDerivedStateFromProps для обновления локального state в случае изменения props.url. А с React Hooks никаких новых абстракций, можно сразу в рендере вызывать обновление state — ура, товарищи, наконец!


Единственно усложнение с React Hooks — пришлось мемоизировать функцию update, чтобы она не изменялась между обновлениями компонента. Когда как в Render Prop функция update является методом класса.


3) Опрос API через одинаковый промежуток времени или Polling


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


Использование:


const AvatarRenderProp3 = ({ username }) => (
 <RenderProp url={`https://api.github.com/users/${username}`} pollInterval={1000}>
...

const AvatarWithHook3 = ({ username }) => {
 const [state, update] = useRequest(
   `https://api.github.com/users/${username}`, 1000
 );
...

Реализация:


function reducer3(state, action) {
 switch (action.type) {
   ...
   case "poll":
     return {
       ...state,
       requestId: state.requestId + 1,
       isFetching: true
     };
   ...
 }
}

Render Prop


class RenderProp3 extends React.Component {
 state = {
  ...
  requestId: 1,
 }
 ...
 timeoutId = null;
 ...
 tryToClearTimeout() {
   if (this.timeoutId) {
     clearTimeout(this.timeoutId);
   }
 }

 poll = () => {
   this.tryToClearTimeout();
   this.timeoutId = setTimeout(() => {
     this.dispatch({ type: 'poll' });
   }, this.props.pollInterval);
 };
 ...

 componentDidUpdate(prevProps, prevState) {
   ...

   if (this.props.pollInterval) {
     if (
       prevState.isFetching !== this.state.isFetching &&
       !this.state.isFetching
     ) {
       this.poll();
     }

     if (prevState.requestId !== this.state.requestId) {
       this.fetch();
     }
   }
 }

 componentWillUnmount() {
   ...
   this.tryToClearTimeout();
 }
 ...

React Hooks


const useRequest3 = (url, pollInterval) => {
  const [state, dispatch] = React.useReducer(reducer, {
    ...
    requestId: 1,
  });

 React.useEffect(() => {
   …(fetch data)
 }, [state.url, state.requestId]);

 React.useEffect(() => {
   if (!pollInterval || state.isFetching) return;
   const timeoutId = setTimeout(() => {
     dispatch({ type: "poll" });
   }, pollInterval);

   return () => {
     clearTimeout(timeoutId);
   };
 }, [pollInterval, state.isFetching]);

...
}

Появился новый prop — pollInterval. При завершении предыдущего запроса через setTimeout мы инкрементируем requestId. С хуками у нас появился еще один useEffect, в котором мы вызываем setTimeout. А старый наш useEffect, который отправляет запрос стал следить еще за одной переменной — requestId, которая говорит нам, что setTimeout отработал, и пора уже запрос отправлять за новой аватаркой.


В Render Prop пришлось написать:


  1. сравнение предыдущего и нового значения requestId и isFetching
  2. очистить timeoutId в двух местах
  3. добавить классу свойство timeoutId

React Hooks позволяют писать коротко и понятно то, что мы привыкли описывать подробнее и не всегда понятно.


4) Что дальше?
Мы можем продолжить расширять функционал нашей утилиты: принимать разную конфигурацию параметров запроса, кеширование данных, преобразование ответа и ошибки, принудительное обновление данных с теми же параметрами — рутинные операции в любом большом веб-приложении. На нашем проекте мы давно это вынесли в отдельный (внимание!) компонент. Да, потому что это был Render Prop. Но с выходом Hooks мы переписали на функцию (useAxiosRequest) и даже нашли некоторые баги в старой реализации. Посмотреть и попробовать можно тут.

Источник: https://habr.com/ru/post/453866/


Интересные статьи

Интересные статьи

В этой статье я поделюсь своим кратким обзором внедрения стейт-менеджера Effector в продуктовый проект на стеке React + TypeScript, а также покажу на примере, как легко э...
Всем привет. Меня зовут Дмитрий Андриянов. Два года я писал на React Native, сейчас работаю в Surf во Flutter отделе и уже более полутора лет пишу на Flutter. В первой части статьи я р...
1С Битрикс: Управление сайтом (БУС) - CMS №1 в России по версии портала “Рейтинг Рунета” за 2018 год. На рынке c 2003 года. За это время БУС не стоял на месте, обрастал новой функциональностью...
Привет, Хабр! Представляю вашему вниманию перевод статьи «React Best Practices & Tips Every React Developer Should Know Pt.1» автора Alex Devero. React — одна из самых популярных библи...
Разработка на javascript иногда становится похожа на работу детектива. Как понять чужой код? Хорошо, если разработчик обладает тонким искусством называть переменные так, чтобы другие поняли суть....