Рассмотрим реализацию запроса данных к 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 к нам пришел из функционального программирования, а для большинства 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 пришлось написать:
- сравнение предыдущего и нового значения requestId и isFetching
- очистить timeoutId в двух местах
- добавить классу свойство timeoutId
React Hooks позволяют писать коротко и понятно то, что мы привыкли описывать подробнее и не всегда понятно.
4) Что дальше?
Мы можем продолжить расширять функционал нашей утилиты: принимать разную конфигурацию параметров запроса, кеширование данных, преобразование ответа и ошибки, принудительное обновление данных с теми же параметрами — рутинные операции в любом большом веб-приложении. На нашем проекте мы давно это вынесли в отдельный (внимание!) компонент. Да, потому что это был Render Prop. Но с выходом Hooks мы переписали на функцию (useAxiosRequest) и даже нашли некоторые баги в старой реализации. Посмотреть и попробовать можно тут.