Хабрахабр

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

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

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

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

Будем рассматривать Render Prop, а не HOC, так как в реализации они очень похожи и у HOC больше недостатков. Чтобы не быть голословной, давайте рассмотрим на примере чем React Hooks лучше (а может все-таки хуже) Render Prop. Я уверена, что многие писали это в своей жизни сотни раз, ну что же посмотрим можно ли еще лучше и проще. Попробуем написать утилиту, которая обрабатывает запрос данных к 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) ; 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];
};

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

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

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

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

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

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

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

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

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

Использование нашей утилиты с 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 в локальном state компонента и передавать новый username из state, а не props как сейчас. Добавим кнопку, которая отправляет запрос с новым username. Так что вынесем этот функционал в нашу утилиту. Но тогда нам придется 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, которую возвращаем в компонент для отправки нового запроса по клику на кнопку.

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

Когда как в Render Prop функция update является методом класса. Единственно усложнение с React Hooks — пришлось мемоизировать функцию 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]); ...
}

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть