В 16.8 версии библиотеки React впервые появились хуки (hooks) — функции, которые упрощают работу с компонентами React и переиспользованием какой-либо логики. В экосистеме React уже есть много дефолтных хуков, но также можно создавать и свои. Я Михаил Карямин, фронтенд-разработчик в Учи.ру, расскажу, как и в каких случаях хуки в React облегчают жизнь разработчику и как с ними работать.

React без хуков и с ними

Чтобы понять, почему хуки упрощают жизнь разработчику, надо посмотреть на то, как писался React раньше. Он был на классовых компонентах: есть стандартный метод рендер, который отвечает за разметку, есть поле state, где хранится объект и все его состояния, есть какие-то свои методы и есть методы жизненных циклов. Всё это выглядит очень громоздко, и практически не актуально.

Классовый компонент

class Welcome extends React.Component {
  state = {
    money: 0
  };

  increaseMoney() {
    this.setState((prevState) => ({
      money: prevState.money++
    }));
  }

  render() {
    return (
      <div>
        Привет, {this.props.name} у тебя {this.state.money}
        <button onClick={this.increaseMoney}>Добавить денег</button>
      </div>
    );
  }
}

Многие сегодняшние проекты React пишутся уже на функциональных компонентах. Есть функция, которая возвращает разметку, и внутри функции есть хуки для хранения состояния (state) или хуки для логики.

Функциональный компонент — new

function Welcome(props) {
  const [money, increaseMoney] = React.useState(100);
  const onClick = () => {
    increaseMoney((prevMoney) => prevMoney++);
  };

  return (
    <div>
      Привет, {props.name} у тебя {money}
      <button onClick={onClick}>Добавить денег</button>
    </div>
  );
}

Важно: несмотря на то что React в классовом виде встречается редко, почитать, как это работает, будет все же не лишним. Возможно, вам придется поддерживать написанный таким способом проект.

До хуков в классовых компонентах для хранения общей переиспользуемой логики самыми распространенными вариантами были так называемые higher-order component (HOC). Это функция, которая оборачивает обычные классовые компоненты. В качестве аргумента она принимает компонент, к которому нужна какая-то переиспользуемая логика. HOC тяжело читаются, во время учебы я долго не мог понять, как тут всё взаимосвязано и куда что передается.

Higher-Order Component

const withFetch = (WrappedComponent) => {
  class WithFetch extends React.Component {
    constructor(props) {
      super(props);
      this.state = {
        movies: []
      };
    }

    componentDidMount() {
      fetch("http://json-faker.onrender.com/movies")
        .then((response) => response.json())
        .then((data) => {
          this.setState({ movies: data.movies });
        });
    }

    render() {
      return (
        <>
          {this.state.movies.length > 0 && (
            <WrappedComponent movies={this.state.movies} />
          )}
        </>
      );
    }
  }

  WithFetch.displayName = `WithFetch(${WithFetch.name})`;

  return WithFetch;
};

Компонент с Higher-Order Component

import MovieContainer from "../component/MovieContainer";
import withFetch from "./MovieWrapper";

class MovieListWithHOC extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <div>
        <h2>Movie list - with HOC</h2>
        <MovieContainer data={this.props.movies} />
      </div>
    );
  }
}

export default withFetch(MovieListWithHOC);

Сегодня вместо HOC используются хуки. Компонент с хуком смотрится намного компактнее и понятнее: вся логика занимает одну строчку «const [loading, data] = useFetch(MOVIE_URI)» — хук возвращает текущее состояние и данные. А если нужны несколько переиспользуемых бизнес-логик, можно просто добавить еще одну строчку и появится дополнительный компонент. В случае с HOC пришлось бы оборачивать компоненты: это не очень красиво и тяжело читается.

Hook

const useFetch = (url) => {
  const [data, setData] = React.useState(null);
  const [loading, setLoading] = React.useState(true);

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(url);
      const data = await response.json();
      setData(data);
      setLoading(false);
    };
    fetchData();
  }, [url]);

  return [loading, data];
};

Компонент с хуком

import { useFetch } from "../hooks/useFetch";
import Movie from "../components/Movie";

const MovieWithHook = () => {
  const MOVIE_URI = "http://json-faker.onrender.com/movies";
  const [loading, data] = useFetch(MOVIE_URI);

  return (
    <div>
      <h2>Moview with hook</h2>
      {loading ? <h3>loading...</h3> : <Movie data={data.movies} />}
    </div>
  );
};

Как пишется собственный хук

Первая итерация

Предлагаю попробовать написать хук и посмотреть, как работает концепт React в рамках одного хука. Для этого возьмем базовый useState, отвечающий за состояние.

Заводим обычную переменную someState, задаем первоначальное значение — пусть будет «0». Добавляем функцию, которая будет ее увеличивать на 1 и возвращать актуальное состояние к переменной. Проверяем в console.log: пишем increaseState и вызываем несколько раз. Все работает, state обновляется — пошел отсчет 1, 2, 3, 4, 5.

Одна проблема: state не защищен, его легко сломать случайно или намеренно. Например, напишем someState = 100. И вместо ожидаемой «5» получим «102». Если бы строчек было много, долго бы пришлось искать, где баг. Чтобы это исправить, переменную надо инкапсулировать, поместить в функцию. Но теперь JS ругается, так как someState оказался в области видимости функции, а не в глобальной.

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

На этом этапе мы получили реализацию функции, которая может хранить какой-то state и изменять его.

const increaseState = (() => {
  let someState = 0;

  return () => {
    someState = someState + 1;
    return someState;
  }
})()

console.log(increaseState())
console.log(increaseState())
console.log(increaseState())
console.log(increaseState())
console.log(increaseState())

Вторая итерация 

Теперь надо написать функцию useState. Она принимает в качестве аргумента первоначальное состояние – initialValue. Хук возвращает массив, который состоит из двух элементов: сначала state, а потом функцию, которая изменяет setState.

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

Чтобы проверить работоспособность, вводим count – state, и setCount — функция, которая будет менять наш state. Count — «0», как initialValue. Меняем его на «2», но переменная в console.log не меняется.

В чем проблема? При деструктуризации массива в JS создаются константы. Наш count «0» записался на 29 строке и не изменится, так как доступа к внутреннему состоянию функции state у нас нет. Count «2» на 32 строке — это новая переменная, внутреннее состояние мы не видим.

Самый простой способ это исправить — вместо переменной из useState возвращать функцию, у которой в момент вызова в замыкании есть value. Так мы увидим внутреннее состояние useState. Для этого прописываем обычную стрелочную функцию и меняем переменные: вместо count поставим getCount. Теперь состояние обновляется.

Функция уже выглядит почти как хук, но вместо обычной переменной первым элементом она возвращает функцию. А нам надо написать полный аналог хука — дефолтного useState.

const useState = (intialValue) => {
  let value = intialValue;
  const getState = () => value;
  const setState = (newValue) => {
    value = newValue
  }

  return [getState, setState];
}

const [getCount, setCount] = useState(0)
console.log(getCount())
setCount(2)
console.log(getCount())

Третья итерация 

Заведем немедленно вызываемую функцию (IIFE) и назовем ее React. В ее теле будет уже написанный хук, только без функции, возвращающей значение, и добавим в тело React переменную value без изначального значения. State будет «value || initialValue». Возвращать будем state, а из функции React возвращаем объект. Первое поле этого объекта как раз наш прототип хука — useState. 

Напишем компонент — назовем Component и добавим внутрь хук, который будет записывать имя: вносим name и setName. Первоначальное значение — «Mike». Если бы у нас было взаимодействие с реальным DOM-ом, функция бы отрисовывала нам изменения в разметку. Но его нет, поэтому будем возвращать функцию render и выводить текущее состояние в console.log.

Также мы будем возвращать импровизированное взаимодействие пользователя с каким-то input: например, с формой ввода имени. Называем ее changeName. В качестве аргумента она принимает новое имя, которое вводит пользователь, и передает в хук.

Получился прототип компонента, который осталось связать с React. Для этого добавляем функцию, называем ее render, в качестве аргумента она принимает Сomponent. В ее теле вызывается сomponent, у него будет вызываться метод render, который отрисовывает консоль и возвращает component.

Заводим переменную: называем app, связываем с React и отрендерим компонент — «Mike» вывелось в консоли. Пробуем поменять имя через App.changeName, подставляем «Vasya», делаем новый render. Все четко, «Vasya» вывелся в консоли. Реализация работает, есть прототип React и компонента. Но в реальном приложении мы часто используем несколько раз в одном компоненте, поэтому будем усложнять задачу. 

Добавим фамилию и взаимодействие с inpit в return — changeSurname. Но теперь в консоли при изменении имени у нас меняется и фамилия. Если фамилию меняем — аналогично. А должно только имя или только фамилия.

Где проблема? Ответ кроется в функции React. Во 2 строчке есть переменная, в которую записываются все вызовы useState, и когда вызов был 1, все отлично. Но как только их становится 2, текущий value перезаписывается. Нужно завести в хук массив states вместо переменной value и добавить переменную index, потому что хук вызываем несколько раз, и надо понимать, какой вызов какому элементу массива принадлежит.

Если в хук завести console.log, можно посмотреть, что хранится в массиве. Оказывается, вместо 1 элемента здесь 2. Надо добавить увеличение индекса, который при каждом вызове должен прибавлять единицу, и сделать сбрасывание индекса при каждом рендере. Иначе каждый раз при срабатывании хука предыдущее состояние будет не перезаписываться, а добавляться в конец массива. В итоге будет расти количество элементов в массиве.

Остается последняя проблема, связанная с замыканием. Из хука мы возвращаем не вызов функции, а просто ссылку на нее. Когда у нас происходит функция render, хук вызывается дважды: сначала он 0, потом 1. На следующем вызове, с changeSurname, он уже 2. Потому что замыкание — это все переменные, доступные в момент вызова функции.

Чтобы это исправить, надо сохранить актуальное состояние индекса внутри useState в момент инициализации. И когда мы вызовем функцию setState, мы уже будем брать не глобальный индекс, который равен 2, а будем использовать именно внутренний, так как он будет верный.

Теперь в консоли все работает, и у нас есть простой функциональный хук.

const React = (() => {
  const states = [];
  let idx = 0;

  const useState = (intialValue) => {
    const state = states[idx] || intialValue;
    const _idx = idx;
    const setState = (newValue) => {
      states[_idx] = newValue;
    };

    idx++;
    return [state, setState];
  };

  const render = (Component) => {
    idx = 0;
    const component = Component();
    component.render();

    return component;
  };

  return {
    useState,
    render
  };
})();

const Component = () => {
  const [name, setName] = React.useState("Mike");
  const [surname, setSurname] = React.useState("Petrov");

  return {
    render: () => console.log(`${name} ${surname}`),
    changeName: (newName) => setName(newName),
    changeSurname: (newSurname) => setSurname(newSurname)
  };
};

let App = React.render(Component);

App = React.render(Component);
App.changeName("Petya");
App = React.render(Component);

Кастомные хуки и их применение

Все кастомные хуки состоят из дефолтных: useState, useEffect, useReducer, useMemo, useCallback. Их можно классифицировать в зависимости от проекта и бизнес-задач. Но я делю кастомные хуки по принципу использования на 6 категорий. 

Первая — listeners. Это обширная группа, к которой можно отнести хуки, которые ловят клик пользователя, положение экрана мобильного устройства, геолокацию и так далее. 

Вторая — UI хуки. Они нужны для работы с CSS, с аудио, с видео.

Третья — side-effects. Хуки, которые работают вне основного потока приложения. Например, они нужны для работы с асинхронностью, с local storage, для изменения title страницы. 

Четвертая — lifecycles. В классовых компонентах очень много инструментов для работы с жизненными циклами, а в функциональных есть только useEffect. Так что приходится часто дописывать хуки: например, useMount, который срабатывает только при монтировании, или useUpdate, который имитирует работу компонента DidUpdate.

Пятая — state. Хуки для удобной работы с состоянием отдельных компонентов и с глобальным состоянием. Такие хуки есть, например, в Redux.

Шестая — animations. Хуки для работы с request animation frame, интервалом, таймаутом. Самая непопулярная группа.

Эта разбивка очень условная, жестких границ у групп нет: в той же категории UI могут быть не только хуки для CSS, аудио и видео.

Как это все выглядит на практике: возьмем хук, отвечающий за переключение темы на сайте или в приложении. Часто это переключение происходит или от системных настроек, или от отдельной кнопки. Чтобы сменить тему, нам нужен компонент с useDarkMode — хук, который возвращает переключение, включение, выключение и текущее состояние.

 Под капотом у него несколько вспомогательных хуков:

  • useMediaQuery — помогает узнать, какая предпочтительная тема у пользователя. Он состоит из дефолтных useState и useEffect. В первой строчке прописываем текущее состояние, а во второй создаем функцию Callback и подписываемся на изменение медиавыражения — чтобы всегда иметь актуальное состояние;

  • useIsFirstRender — основан на useRef. При первом его срабатывании мы заходим в условия и переписываем из isFirstRender current = false. При ре-рендере этот хук вернет false, и мы попадем, куда нам нужно;

  • useUpdateEffect — он почти аналогичен стандартному useEffect, но не срабатывает при первом рендере. В классовых компонентах был componentDidUpdate, в функциональных его нет, и приходится придумывать что-то для замены.

Кроме этого useDarkMode использует дефолтный хук useCallback. Благодаря ему при перерисовке у нас сохранится ссылка на функцию, которую мы обернули. При перерисовке в React у нас происходит переинициализация функции, и без useCallback наш оптимизированный компонент посчитает, что у нас изменился prop, и сам перерисуется.

В итоге у нас простая цепочка: в теле хука useMediaQuery показывает, какую тему предпочитает пользователь. Потом хук useLocalStorage помогает внести его выбор в local storage, и при перегрузке страницы не будет морганий — нужная тема сразу включится. Дальше если у пользователя обновится предпочтение, сработает useUpdateEffect. А функция возвращает 3 Callback и текущее состояние.

Тонкости useEffect

Хук useEffect — один из самых любопытных, он заменяет 3 метода жизненного цикла в классовых. Предлагаю разобрать один из распространенных кейсов с useEffect.

Есть компонент, в котором мы что-то отрисовываем на основе данных, полученных с сервера. Основные props – это name, surname и number. Меняется один из props — срабатывает запрос сервера, мы это отрисовываем. И здесь может получиться так, что изменился 1 props, а сработали сразу все 3. Возникает лишняя нагрузка на сервер, могут появиться баги, потому что данные придут не в той последовательности. Чтобы хук срабатывал только для конкретного props, надо сделать 3 разных useEffect.

function Example({ currentType, name, surname, number }) {
  const [infoByName, setInfoByName] = React.useState();
  const [infoBySurname, setInfoBySurname] = React.useState();
  const [infoByNumber, setInfoByNumber] = React.useState();

  React.useEffect(() => {
    const fetchByName = async () => {
      const response = await fetch("URI");
      const data = await response.json();
      setInfoByName(data);
    };
    fetchByName();
  }, [name]);

  React.useEffect(() => {
    const fetchBySurname = async () => {
      const response = await fetch("URI");
      const data = await response.json();
      setInfoBySurname(data);
    };
    fetchBySurname();
  }, [surname]);

  React.useEffect(() => {
    const fetchByNumber = async () => {
      const response = await fetch("URI");
      const data = await response.json();
      setInfoByNumber(data);
    };
    fetchByNumber();
  }, [number]);

  return (
    <div>
      {currentType === "name" && <div>{infoByName}</div>}
      {currentType === "surname" && <div>{infoBySurname}</div>}
      {currentType === "number" && <div>{infoByNumber}</div>}
    </div>
  );
}

Одна из интересных особенностей useEffect — если вторым аргументом передать пустой массив, эффект сработает всего раз: при монтировании и размонтировании. На практике, если я проверяю, у меня вышел не 1 рендер, а 2. Смотрим в changelog React и видим «Stricter strict mode», строгий режим стал строже. С марта 2022 года React стал автоматически размонтировать и обратно монтировать каждый компонент при первом рендере.

const BadUseEffectOnce = () => {
  const [count, setCount] = useState(0);

  React.useEffect(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  return <div>{count}</div>;
};

export default function App() {
  return (
    <div className="app">
      <React.StrictMode>
        <h1>
          Количество ререндеров: <BadUseEffectOnce />
        </h1>
      </React.StrictMode>
    </div>
  );
}

Чтобы это исправить, можно просто отключить strict mode, но я не советую так делать, особенно если ваш проект будет развиваться еще несколько лет, и вам, возможно, придется обновлять версию React. Strict mode нужен, чтобы мы могли увидеть узкие места в приложении, которые в текущей версии React не вызывают багов, но могут вызвать в следующей. Strict mode заранее предупреждает, что это нужно исправить. Если его отключить, потом с большой долей вероятности вам на голову свалится куча неожиданных багов, и вы не сможете просто и легко обновиться до более новой версии React.

const BadUseEffectOnce = () => {
  const [count, setCount] = useState(0);

  React.useEffect(() => {
    setCount((prevCount) => prevCount + 1);
  }, []);

  return <div>{count}</div>;
};

export default function App() {
  return (
    <div className="app">
      <h1>
        Количество ререндеров: <BadUseEffectOnce />
      </h1>
    </div>
  );
}

Выход простой — использовать useRef и записывать, что при первом рендере заходим в условия, которые находятся в useEffect. Мы выполняем нашу логику функции и записываем: isFirstRender = false. В итоге, при первоначальной реализации было 2 рендера, а сейчас 1.

const GoodUseEffectOnce = () => {
  const [count, setCount] = useState(0);
  const isFirstRender = React.useRef(true);

  React.useEffect(() => {
    if (isFirstRender.current) {
      setCount((prevCount) => prevCount + 1);
      isFirstRender.current = false;
    }
  }, []);

  return <div>{count}</div>;
};

export default function App() {
  return (
    <div className="app">
      <React.StrictMode>
        <h1>
          Количество ререндеров (Bad): <BadUseEffectOnce />
          Количество ререндеров (Good): <GoodUseEffectOnce />
        </h1>
      </React.StrictMode>
    </div>
  );
}

Последний пример с useEffect — когда нужно подписаться на какое-то событие с помощью этого хука. Например, на получение каких-то данных с удаленного сервера или на клик пользователя. И здесь вылезает баг: я кликаю 1 раз, но у нас показывает, будто совершено 2 клика.

const BadUseEffectOnce = () => {
  const [count, setCount] = useState(0);

  React.useEffect(() => {
    document.addEventListener("click", () => {
      setCount((prevCount) => prevCount + 1);
    });
  }, []);

  return <div>{count}</div>;
};

export default function App() {
  return (
    <div className="app">
      <React.StrictMode>
        <h1>
          Количество ререндеров (Bad): <BadUseEffectOnce />
        </h1>
      </React.StrictMode>
    </div>
  );
}

Самое простое решение — записать функцию в переменную. Затем при монтировании мы подписываемся на какое-то событие, а при размонтировании — отписываемся. Тогда все будет отлично работать. Если этот способ использовать не выходит, стоит перенести эту логику в какой-то state manager: Redux или MobX.

const GoodUseEffectOnce = () => {
  const [count, setCount] = useState(0);

  React.useEffect(() => {
    const listener = () => {
      setCount((prevCount) => prevCount + 1);
    };
    document.addEventListener("click", listener);

    return () => {
      document.removeEventListener("click", listener);
    };
  }, []);

  return <div>{count}</div>;
};

export default function App() {
  return (
    <div className="app">
      <React.StrictMode>
        <h1>
          Количество ререндеров (Good): <GoodUseEffectOnce />
        </h1>
      </React.StrictMode>
    </div>
  );
}

Нюансы работы с useState

Представим, что есть пользовательский компонент, с которого надо собрать статистику: сколько кликов человек на нем делает. Внутри есть 2 функции: первая — какой-то счетчик, вторая — при выходе пользователь отправляет статистику нам на сервер. В целом, это работает, но при каждом изменении useState у нас будет происходить ре-рендер. React оптимизирован для ре-рендеров, но если компонент сложнее, чем из двух div, это будет плохо сказываться на перфомансе.

Чтобы избежать этого, возьмем useRef вместо useState. В классовых компонентах его аналогом будет createRef, но в функциональных useRef полезнее и чаще применяется. В нем можно хранить state, в том числе при перерисовках, и он не вызывает ре-рендер. Так что если текущее состояние не используется где-то для отображения пользователю, лучше использовать useRef. Но если нужно обновленное состояние в разметке — берем useState.

И напоследок приведу кейс, который нередко встречается на собеседованиях у джунов, пре-миддлов и даже миддлов. Если мы в одной функции будем несколько раз обновлять state и брать текущее значение из вызова хука, то очень возможны какие-то баги — state не всегда синхронно обновляется. Лучше исключить такие риски и текущее значение брать не из useState count, а из аргумента — так у него всегда будет актуальное состояние. 


Хочешь развивать школьный EdTech вместе с нами — присоединяйся к команде Учи.ру!

Комментарии (2)


  1. Irina_Zakharova
    00.00.0000 00:00

    Код тяжеловато читается с картинок


    1. m_u_x_a_u_ji Автор
      00.00.0000 00:00

      теперь код вместо картинок