О хуках в фронтенд-разработке на Хабре писали уже не раз, и в этой статье мы не сделаем великого открытия. Наша цель другая – рассказать про React Hooks настолько подробно и просто без трудной терминологии, насколько это возможно. Чтобы после прочтения статьи каждый понял про хуки всё. Эта статья будет полезна как начинающим React-разработчикам, так и тем, кто хочет, не уходя в глубины документации, получить практическую информацию в сжатом виде. 

На заре React-человечества

Когда сообщество впервые познакомилось с функциональными компонентами, они служили только для того, чтобы выводить информацию. У них не было ни состояния, ни методов жизненного цикла. Они были очень простыми – в этом и заключалась их проблема. Часто возникала ситуация, что компонент, написанный в функциональном стиле, должен был в дальнейшем иметь состояние или методы жизненного цикла, а такой возможности не было. Приходилось переписывать их в классовые компоненты, а это далеко не самое интересное занятие для разработчика. 

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

Хук useState – простой хук для разработчика, но важный для всего приложения

Начнём с самого простого и важного хука – useState. Из самого названия становится понятно, что он связан с состоянием компонента. Именно благодаря ему у функциональных компонентов появилось состояние.

Приведу простой пример использования данного хука, а потом разберем его подробнее.

const App = () => {
  const [value, valueChange] = useState(0);
 
  return (
    <div>
      {value}
      <button onClick={() => valueChange(value + 1)}>
        Увеличить значение на 1
      </button>
    </div>
  );
};

По сути, этот пример состоит из состояния value, которое содержит в себе целочисленное значение, и кнопки “Увеличить значение на 1”. При нажатии на нее состояние value увеличивается на 1.

Посмотрите на строчку:

const [value, valueChange] = useState(0);

В ней создается состояние и метод, который будет менять это значение. Хук useState по сути принимает в качестве параметра начальное значение, то есть на начальном этапе наш value будет иметь значение 1. И возвращает useState массив из двух элементов: первый – состояние, второй – метод, который будет его изменять. Разработчики хуков использовали довольно изящный подход. При использовании деструктуризации он позволяет задать любое значение состояния и метода минимальным количеством кода.

Обратите внимание еще на одну строчку:

<button onClick={() => valueChange(value + 1)}>
  увеличить значение на 1
</button>

Тут добавлен обработчик события нажатия на кнопку. Поясню: при нажатии на кнопку мы вызываем метод valueChange и отправляем туда новое значение – в нашем случае увеличенное на один.

В остальном всё как с обычным состоянием компонента. Основное отличие: в классовом компоненте мы можем создать только одно общее состояние компонента, а в функциональном – несколько, и они будут независимы друг от друга, но каждое из них будет вызывать рендеринг компонентов.

Хук useContext – сквозь пространство

Чтобы передать какие-то данные в компонент, мы можем использовать props. Но есть и альтернативный способ – context.

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

Чтобы было понятнее, создали небольшой пример, который позволит понять его работу. У нас есть три компонента. Первый из них External – внешний, второй – Intermediate, то есть промежуточный, а третий назовем Internal, и он будет внутренним. По сути, все они будут вложены друг в друга. Наша задача – передача данных из компонента External в компонент Internal, минуя Intermediate, так как к нему эти данные отношения не имеют.

import {createContext, useContext} from "react";

const MyContext = createContext("without provider");
 
const External = () => {
  return (
    <MyContext.Provider value="Hello, i am External">
      <Intermediate />
    </MyContext.Provider>
  );
};
 
const Intermediate = () => {
  return <Internal />;
};
 
const Internal = () => {
  const context = useContext(MyContext);
 
  return `I am Internal component. I have got the message from External: "${context}"`;
};

Чтобы использовать контекст, мы создаём объект MyContext, вызывая метод createContext.  В компоненте External оборачиваем компонент Intermediate в компонент MyContext.Provider. Тем самым говорим, что все вложенные в него компоненты смогут получить доступ к данным, которые мы передаем, помещая их в параметр value. 

Причём они будут доступны только в тех компонентах, в которых нам это нужно. Для этого мы должны использовать хук useContext, а в качестве аргумента у него будет объект MyContext. Хук useContext вернёт нам данные, переданные в параметр value у MyContext.Provider, которые мы поместим в переменную context. Обратите внимание, что в качестве аргумента в createContext мы передали строку (“without context”). Его значение попадет в переменную context в том случае, если вы вдруг забудете создать обертку MyContext.Provider, то есть он поможет не допустить ошибку из-за невнимательности.

Благодаря хуку useContext можно использовать context в функциональных компонентах, и данные будут попадать только в те компоненты, в которых они нужны. Также он избавит от проблемы с drops drilling.

Хуки useEffect и useLayoutEffect – придание жизни компонентам, а точнее – придание методов жизненного цикла

Если вы работали с классовыми компонентами, то знакомы с методами жизненного цикла. Они служат для того, чтобы совершать какие-то операции на разных стадиях жизни компонента. Для этого у нас есть два хука – useEffect и useLayoutEffect. Они похожи между собой, за исключением небольшой разницы в рендеринге. В случае с useLayoutEffect React не запускает рендеринг построенного DOM дерева до тех пор, пока не отработает useLayoutEffect. Если же мы берём useEffect, то React сразу запускает рендеринг построенного DOM, не дожидаясь запуска useEffect.

С помощью этих двух хуков в функциональных компонентах можно смоделировать работу трех методов жизненного цикла – componentDidMount, componentDidUpdate, componentWillUnmount. Более точно их работу имитирует useLayoutEffect, так как в классовых компонентах отрисовка DOM-дерева не запускается до тех пор, пока не отработает метод componentDidMount.

Поскольку эти два хука имеют один и тот же интерфейс, продемонстрируем его на более популярном хуке – useEffect, а для другого всё будет аналогично.

useEffect принимает в себя два аргумента:

  • callback. Внутри него вся полезная нагрузка, которую мы хотим описать. Например, можно делать запросы на сервер, задание обработчиков событий на документ или что-то ещё; 

  • массив, состоящий из аргументов. При изменении значения внутри любого из них будет запускаться наш callback. Именно благодаря этому аргументу мы можем имитировать методы жизненного цикла.

Рассмотрим имитацию componentDidMount. Хук useEffect запускается не только при изменении элементов массива из второго аргумента, но также и после монтирования компонента. Фактически componentDidMount запускается на той же стадии. Если мы укажем в качестве второго аргумента пустой массив, callback запустится на стадии монтирования компонента. А поскольку никаких зависимостей для хука внутри массива мы не задали, то аргумент callback не будет больше запускаться.

const App = ({data}) => {
  useEffect(() => {
    console.log("componentDidMount");
  }, []);
 
  return null;
};

Имитация componentDidUpdate также возможна. Но в случае с хуками есть лучший способ. Поскольку мы можем указать в массиве только те зависимости, которые нам нужны, у нас есть более гибкий аналог метода componentDidUpdate, который запускается при изменении необходимых параметров. Если нужно сделать аналогию componentDidUpdate, в качестве зависимостей можно указать все параметры и состояния. При этом важно учитывать, что useEffect запускается и на стадии монтирования. 

const App = ({data}) => {
  useEffect(() => {
    console.log("componentDidUpdate");
  }, [data]);
 
  return null;
};

Теперь рассмотрим имитацию componentWillUnmount. Для этого просто возвращаем из useEffect callback. Практически возвращаемый callback – это и есть аналог componentWillUnmount, который часто применяется для отвязывания обработчиков событий документа. В случае с функциональным компонентом мы будем отвязывать их внутри возвращаемого callback.

const App = ({data}) => {
  useEffect(() => {
    return () => {
      console.log("componentWillUnmount");
    };
  }, []);
 
  return null;
};

Хук useRef – прямая связь с узлами и не только

Бывают ситуации, когда необходимо обратиться к какому-то DOM-объекту напрямую. Для этого существует хук useRef.

Рассмотрим пример:

const App = () => {
  const ref = useRef();
 
  useEffect(() => {
    console.log(ref.current);
  }, []);
 
  return <div ref={ref} />;
};

Мы создаём объект ref и указываем его в качестве элемента, обозначающего DOM-объект, к которому мы хотим обратиться, а также прописываем этот объект в качестве параметра. Далее мы можем взаимодействовать с Dom-объектом напрямую, как если бы мы нашли его с помощью селектора. Для этого используем свойство current у объекта ref.

Помимо этого можно применить useRef, если нам нужно будет запомнить данные в компоненте. Но не рекомендуем вызывать рендеринг в случае изменения состояния компонента. В классовых компонентах для этой цели обычно используют this.

Хук useReducer – снова идём сквозь пространство

Разработчикам React так понравился Redux, что они решили добавить его аналог в состав React. Этот хук позволяет вынести данные из компонентов.

import {useReducer} from "react";
 
const initialState = {count: 0};
 
function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return {
        ...state,
        count: state.count + 1,
      };
    case "decrement":
      return {
        ...state,
        count: state.count - 1,
      };
    default:
      throw new Error();
  }
}
 
const App = () => {
  const [state, dispatch] = useReducer(reducer, initialState);
 
  return (
    <>
      {state.count}
      <button onClick={() => dispatch({type: "decrement"})}>-</button>
      <button onClick={() => dispatch({type: "increment"})}>+</button>
    </>
  );
};

У него есть преимущество: вне зависимости от того, как компоненты нашего приложения будут вложены друг в друга, мы сможем отобразить данные в любом компоненте.

Хук useMemo – оптимизируй вычисления

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

const MyComponent = ({a, b}) => {
  const sqrt = a * a;
 
  return (
    <div>
      <div>А в квадрате: {sqrt}</div>
      <div>B: {b}</div>
    </div>
  );
};

В этой ситуации компонент перерендеривается в том случае, если изменяется один из параметров – a или b. Представим, что у нас много раз изменяется параметр b, при этом параметр a остаётся прежним. В таком случае мы много раз вычисляем одно и то же произведение, которое помещаем в переменную sqrt. Но зачем нам это, если параметр a в этом случае остаётся прежним? Получается, мы лишний раз нагружаем наш ПК вычислениями одного и того же. И хотя в данном случае операция произведения не самая “энергозатратная”, в других ситуациях возможна лишняя нагрузка. Избежать избыточных вычислений нам помогает хук useMemo. Давайте немного преобразуем наш пример.

const MyComponent = ({a, b}) => {
  const sqrt = useMemo(() => a * a, [a]);
 
  return (
    <div>
      <div>А в квадрате: {sqrt}</div>
      <div>B: {b}</div>
    </div>
  );
};

Тут всё осталось по-прежнему, за исключением ситуации, когда мы обернули наше произведение в хук useMemo, в который передали callback и массив зависимостей. По сути они работают также, как и в useEffect: как только меняется какая-то зависимость из массива, запускается callback, который рассчитывает другое значение. Если ни одна зависимость не поменялась, то при рендеринге в переменную будет подставлено предыдущее вычисленное значение.

Хук useCallback – ещё больше оптимизации

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

const ControlPannel = memo(({changer}) => {
  return (
    <div>
      <button onClick={changer}>+</button>
    </div>
  );
});
 
const App = () => {
  const [value, valueChange] = useState(Math.random());
 
  const changer = () => valueChange(Math.random());
 
  return (
    <div>
      {value}
      <ControlPannel changer={changer} />
    </div>
  );
};

В данном примере представлены два компонента, один из них – ControlPanel, который отвечает за стилизацию контрольной панели. В ней всего одна кнопка, которая меняет состояние родительского компонента. В качестве параметра в него передан метод changer, который внутри себя содержит вызов метода valueChange, он-то и обновляет состояние. Для простоты изменим значение состояние, просто поместив туда случайное число. Мы специально обернули ControlPanel в memo, чтобы этот компонент перерисовывался только в том случае, если изменились его параметры. Однако в данном случае у нас возникает проблема: при каждой отрисовке компонента App мы будем заново создавать метод changer. Хотя сигнатура у метода будет одинаковой, каждый раз будет создан новый метод, следовательно, у ControlPanel будут происходить повторные рендеринги, но по сути ничего не меняется. В этом случае в качестве параметра будут передаваться разные реализации одной и той же функции.

Избежать этого поможет useCallback.

const ControlPannel = memo(({increment}) => {
  return (
    <div>
      <button onClick={increment}>+</button>
    </div>
  );
});
 
const App = () => {
  const [value, valueChange] = useState(Math.random());
 
  const increment = useCallback(() => valueChange(Math.random()), []);
 
  return (
    <div>
      {value}
      <ControlPannel increment={increment} />
    </div>
  );
};

Благодаря этому хуку мы единожды создаем метод и обновляем его только тогда, когда меняется какой-то из параметров, которые мы поместим в качестве элемента массива и передаем вторым аргументом – по аналогии с массивами в других хуках. Так мы мемоизируем наш метод,и у нас не происходит лишний рендеринг, как это было ранее.

Пользовательский хук – создай мир своими руками

Пользовательские хуки – это те же самые функции, которые внутри себя используют какие-либо из стандартных хуков. Единственное требование, которое здесь необходимо соблюдать – относиться к ним, как к хукам. То есть, соблюдать правила, что мы используем при работе с хуками: не вызывать их внутри условных конструкций (таких, как if или switch) и внутри циклов (например for), а также не использовать хуки внутри колбэков других хуков. 

Для того чтобы все в команде соблюдали указанные правила и понимали, что это хуки, а не просто методы, называть их лучше в формате useИмяХука.

Рассмотрим пример пользовательского хука:

const useSingleLog = () => {
  useEffect(() => {
    console.log("I am single log");
  }, []);
};

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

Спасибо за внимание! Надеемся, что материал был вам полезен. 

P.S. Если у вас есть базовые знания Frontend и вы хотите их углубить, приглашаем зарегистрироваться на наш онлайн-практикум (до 28 февраля). Также 24 февраля проведем вебинар для всех желающих.

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


  1. dark_ruby
    18.02.2022 15:31
    +5

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


    1. nin-jin
      18.02.2022 15:41
      +2

      Реакт компоненты не являются чистыми функциями, вас обманули.


      1. jMas
        18.02.2022 16:15
        +2

        По-подробней пожалуйста. Если функция использует хук, да, она хранит состояние за пределами функции, (не помню как этот менеджер данных называется в реакте), но буду признателен за развёрнутый ответ: «а что кроме этого».


        1. nin-jin
          18.02.2022 17:06

          Кроме этого есть состояние в замыканиях и контекстах.


          1. funca
            19.02.2022 00:44

            Абрамов писал, что для него тут важнее идемпотентность функций, нежели чистота. Если наличие скрытых состояний не мешает функции возвращать одно и то же, то какая разница?


            1. TheShock
              19.02.2022 09:44
              +4

              Как она можуть возвращать одно и то же, если это зависит от стейта и контекста, а не только от аргументов?

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


              1. funca
                19.02.2022 15:21
                +1

                https://overreacted.io/react-as-a-ui-runtime/#purity. Идемпотентность это свойство отдельной операции, а не системы. В том же смысле, что SELECT в SQL или GET в ReST должны быть идемпотентными, хотя содержимое базы может изменяться другими операциями, что повлияет на результат. Чистота функций в ФП связана с сылочной прозрачностью, которая в свою очередь тоже связана с идемпотентностью, но не только.

                Функциональный компонент в React сам не должен менять стейт или контекст в процессе исполнения. В dev режиме это даже проверяется, запуская рендер дважды. Контекст фактически является для неё данными - indirect input.


    1. Pavel1114
      19.02.2022 06:04

      Если интересно внутреннее устройство хуков и вообще реакта, могу порекомендовать youtube канал «АйТи Синяка» плейлист по reactjs


  1. laisto
    18.02.2022 17:52

    ребят, я джун, пишу на классовых компонентах. на хуки не тянет, по одной простой причине - не вижу возможности менять однотипные переменные. скажем, есть форма с тремя текстовыми инпутами, в классовом пишем что-то типа

    change = (name, val) => {
    	this.setState({[name]:val})
    }

    и будь у нас хоть 100 инпутов с разными переменными, используется одна функция. что в таком случае делается с хуками?


    1. Layan
      18.02.2022 18:19
      +4

      Да легко. Вот это простейший пример, и прям так писать не стоит.

      const component = () => {
          const [state, setState] = useState({});
          const change = useCallback((name, value) => {
              setState({...state, [name]: value});        
          });
          // ...
      };
      


    1. popuguytheparrot
      21.02.2022 10:42

      взять form-manager типо react-hook-form, чтобы клиент не умирал от ререндера формы


  1. Alexandroppolus
    18.02.2022 18:08

    Обратите внимание, что в качестве аргумента в createContext мы передали строку (“without context”). Его значение попадет в переменную context в том случае, если вы вдруг забудете создать обертку MyContext.Provider, то есть он поможет не допустить ошибку из-за невнимательности.

    Вот лучше бы они сделали выброс исключения, если нет провайдера. Тогда в TS можно было бы без всяких приведений типов (или без дурацких заглушек) делать просто createContext<MyType>()


    1. faiwer
      20.02.2022 23:16

      Вот лучше бы они сделали выброс исключения

      Я бы за такое спасибо не сказал. Бывают ситуации когда контекст несёт вспомогательную роль. Два хука делать?


      1. Alexandroppolus
        21.02.2022 02:32
        +1

        Нет, зачем 2? Просто сделать в createContext необязательное дефолтное значение, и только если оно не указано и нет провайдера, вываливать эксепшн.

        Сейчас, если контекст не вспомогательный и провайдер точно нужен, то приходится либо createContext<MyType | null>(null), что неудобно в использовании, либо createContext<MyType>(null as unknown as MyType), тоже не очень красиво и не "ts-way", либо воткнуть туда бессмысленную заглушку.


  1. jonezq
    18.02.2022 18:44
    -1

    <button onClick={() => valueChange(value + 1)}>
    увеличить значение на 1
    </button>

    Антипаттерн, дальше лень читать


    1. Layan
      18.02.2022 19:49

      Вы бы до конца дочитали, там как раз про оптимизацию такого случая и написано. Хотя, и итоговый вариант можно оптимизировать лучше.


      1. jonezq
        18.02.2022 22:04
        +1

        https://overreacted.io/react-as-a-ui-runtime/#batching

        рекомендую ознакомиться(можно обобщить на весь блог Дэна Абрамова)


    1. e1cb4
      19.02.2022 20:21

      А в чем заключается антипаттерн? Если в незавернутом в usecallback инлайнере, то в данном случае я б сказал что антипаттерн как раз наоборот - оборачивать все подряд направо и налево в мемоизаторы различных мастей.


      1. Alexandroppolus
        19.02.2022 21:29

        Судя по ссылке, антипаттерн в использовании valueChange(someFunc(value)) вместо valueChange(someFunc). Конкретно в данном примере никаких проблем не будет, но второй вариант в общем и целом полезнее, в том числе под useCallback.


  1. prognosis
    19.02.2022 10:18

    Про useRef акцент неправильный. Этот паттерн в первую очередь был нужен для хранения и передачи ссылок на DOM-элементы. А сейчас предполагается универсальным и сохраняет случайные данные в инстансе без его обновления.


  1. St1ggy
    21.02.2022 10:51

    А как же useImperativeHandle? Считаю, очень заслуживает упоминания наряду с useRef, позволяет вызывать методы дочернего функционального компонента.