Всем привет!
Настолько мне нравятся реакт хуки, что я решил написать о них статью. Им правда уже мильен лет, но все равно, больно уж крутой это функционал. Да и статей тоже мильен, но я хочу свою.
Постараюсь написать как можно более просто, но базовые знания js все таки пригодятся. Если когда-нибудь писали To-Do List на реакте, смело читайте.
Раньше компоненты React делились на 2 типа: классовые и функциональные. Можно встретить и другие названия, которые объясняют это разделение: stateful и stateless соответственно.
Все дело в том, что для классовых компонентов при рендеринге создается целый экземпляр, который может содержать какую то информацию в себе (то же состояние, например). Функция же просто выполняется, а в конце возвращает то, что нужно отрендерить. Запомните это, это важно для понимания некоторых вещей и даже пригодится ближе к концу этой статьи.
Поэтому раньше было так, функциональные компоненты использовались только для каких-либо примитивных частей интерфейса типа иконок, кнопок и т. д., можно сказать проще - использовались в случаях, когда нам не нужно локальное состояние. Почему локальное? Потому что такие компоненты вполне можно было использовать с библиотеками менеджмента состояния, например, Redux. Метод connect из пакета react-redux прекрасно работает и с теми, и с теми. Разве что для классовых есть поддержка довольно прикольного синтаксиса декораторов типа:
%lang(js)% import React from 'react'; import connect from 'react-redux'; const mapStateToProps = () => {...} @connect(mapStateToProps, ...) class Button extends React.Component { //... }
Но довольно уже об этом рассуждать, все это вступление нужно для того, чтобы сказать, что
С появлением React Hooks функциональные компоненты перестали быть stateless.
Это означает, что теперь целая страница может быть функциональным компонентом. На данный момент с помощью хуков нельзя реализовать 3 метода жизненного цикла:
getSnapshotBeforeUpdate, componentDidCatch и getDerivedStateFromError. Но по заявлению разработчиков React работа по добавлению данного функционала ведется в данный момент, к тому же, основываясь на опыте, могу сказать, что используются они крайне редко.
Лично мне функциональные компоненты нравятся больше, чем классовые, есть в этом какая-то своя эстетика. Функциональная парадигма буквально приказывает нам использовать такую замечательную вещь как чистая функция и это очень круто и удобно в использовании. Действительно, рассматривать интерфейс как большую функцию, которая берет какие то данные и по сути возвращает (рендерит) нам UI - хороший подход, как по мне. На этой ноте я закончу рассказывать о всяких немного даже оффтоп вещах и перейду к хукам. Я расположил их в порядке от самых часто используемых к тем, что я ни разу не использовал :)
Главное правило: в теле функционального компонента не должно быть кода с побочными эффектами, даже банального console.log! И второе: хуки должны вызываться безусловно. Если мы вызвали его в первый рендер, должны вызывать и в каждый последующий. Работает и обратное: если не вызвали в первый, не вызываем никогда.
И еще кое что:
Хуки не будут работать с классовыми компонентами.
Начинаем!
- useState
%lang(js)% const [state, setState] = useState(initialState);
Этот хук как раз таки и используется для того, чтобы добавить состояние в функциональный компонент. При этом этих состояний может быть сколько угодно много (в классовых же компонентах может быть только один общий объект состояния) и у каждого из них будет своя функция setState.
Чтобы создать состояние, нужно просто вызвать setState, передав в аргумент начальное состояние. Все! Метод вернет вам кортеж из двух элементов: state - нужен, чтобы читать, setState - нужен, чтобы писать. Все предельно просто.
setState поддержвает два типа аргументов, как и в классовых компонентах
%lang(js)% // функция setState(prevState => ({...prevState, /* что то обновленное */ })); // кстати, перерендер не произойдет, если вернуть просто prevState (без изменений) // значение setState({ isDisabled: true }); // или setState(0); // ... и любой другой тип
Важным нюансом является то, что в отличие от setState в классовых компонентах автомерж объектов не произойдет, то есть:
%lang(js)% // если состояние равно { a: 1, b: 2, c: 3 } // после setState({ a: 4 }); // мы получим состояние { a: 4 } // а не { a: 4, b: 2, c: 3 } // как было бы в классовом компоненте
Вообще говоря, не рекомендую использовать useState, если ваше состояние непримитивное значение (например, объект). Для этих целей лучше подходит useReducer, о нем будет рассказано чуть-чуть позже.
Еще одно важное уточнение: классовому setState можно передать опциональный второй параметр. Это коллбэк и он будет выполнен после того, как setState выполнится и произойдет перерендер компонента (это полезно, потому что setState - штука асинхронная, проще говоря, состояние меняется не сразу после вызова setState). Так вот, в setState от useState второй аргумент передать нельзя и такие штуки делать нужно с помощью useEffect (мы до него дойдем).
И еще кое что: дальше мы будем говорить о таких штуках как deps (dependencies). Пока может непонятно о чем это вообще, но дальше Вы увидите. Суть в том, что React гарантирует неизменность функции setState между рендерами, поэтому передавать setState в массив deps необязательно.
- useEffect
Конечно же дальше идет useEffect и это логично, так как этот хук супер полезный. Он используется в качестве замены componentDidMount, componentDidUpdate и componentWillUnmount для функциональных компонентов.
%lang(js)% useEffect( () => { // ... }, [deps] // - массив зависимостей );
Давайте разбираться. Первым аргументом (и он обязательный) передается коллбэк. Он будет вызываться в некоторых случаях. А вот в каких случаях мы сейчас рассмотрим.
Для начала, он, конечно, вызовется при первом рендере.
Второй параметр опциональный - это как раз массив deps и смысл заключается в том, что как только изменилось значение любой переменной, которую мы передали в deps вызовется коллбэк. Если хотите, можете рассматривать это как некого наблюдателя.
Важный момент: сравнение старого и нового значение происходит по алгоритму Object.is. Это означает, что изменения, например, в объекте будут видимы для React только в том случае, если поменялась ссылка на переданный объект.
Идем дальше. Так как второй аргумент опциональный, можно туда ничего не передавать. Тогда коллбэк будет вызываться при каждом обновлении компонента.
Если передать туда пустой массив, то коллбэк вызовется только в первый рендер. И это логично, мы по сути говорим: "вызывай колбэк когда что-то из этого списка обновится", а список пустой.
Ну и если же передать туда массив с какими либо элементами, коллбэк будет вызываться при изменении значения одного из них.
Смотрим пример:
%lang(js)% import React, { useEffect } from 'react'; const Button = (props) => { const { caption } = props; useEffect(() => console.log(caption), [caption]); return <button type="button">{caption}</button>; }
Каждый раз, когда мы будем передавать новый caption, он будет логироваться. Обратите внимание, что коллбэк useEffect - отличное место для вызова различной нефункциональной лабуды с побочными эффектами.
Обычно, хорошей практикой является передача в deps всех переменных, значения которых используются в теле коллбэка useEffect.
Еще одна важная возможность useEffect - это то, что можно вернуть из коллбэка функцию сброса. И это лучшее место для удаления разных подписок и прочего.
Например,
%lang(js)% import React, { useState, useEffect } from 'react'; const Counter = () => { const [value, setValue] = useState(0); useEffect(() => { const interval = setInterval(() => setValue((prev) => prev + 1), 100); return () => clearInterval(interval); }, []); return <div>{value}</div>; }
После размонтирования компонента interval очистится и утечки памяти не произойдет.
А еще попробуйте ответить на вопрос когда этот interval будет создаваться.
Еще один важный момент: хук useEffect будет вызывать коллбэк, когда компонент уже прикреплен к DOM, либо когда перерисовка уже произошла. Это значит, что в теле коллбэка useEffect можно использовать штуки типа:
%lang(js)% document.querySelector('#link').classList.add('hidden') // где document.querySelector('#link') - элемент, который рендерится этим компонентом // для этого можно также использовать рефы: linkRef.current.classList.add('hidden');
Запомните механизм работы deps, он еще нам встретится.
- useCallback
Мы уже поговорили о состоянии и методах жизненного цикла. Логично сейчас поговорить об обработчиках событий.
%lang(js)% const callback = useCallback( () => { // какая-то логика }, [deps] // - массив зависимостей );
Следует отметить, что в случае с useCallback второй аргумент обязателен. Если Вы не хотите, чтобы Ваш коллбэк когда-либо обновлялся, нужно передать пустой массив.
Пример:
%lang(js)% import React, { useCallback } from 'react'; const LogOnClick = (props) => { const { onClick } = props; const callback = useCallback( (event) => { console.log(event); typeof onClick === 'function' && onClick(event); }, [onClick] ); return <div onClick={callback}>Click me</div>; }
Такой компонент будет выводить в консоль event при клике, более того, он будет вызывать onClick, который был передан ему сверху, через пропсы. Мы добавляем onClick из пропсов в deps, чтобы при изменении onClick (когда родитель начнет передавать другую функцию) наш обработчик клика тоже поменялся и работал корректно.
Обратите внимание, что функция, созданная useCallback может принимать любые аргументы.
Напоследок скажу, что этот хук можно заменить вот такой конструкцией:
%lang(js)% useCallback(fn, deps) /* делает то же самое, что и */ useMemo(() => fn, deps)
но о useMemo мы поговорим позже.
- useContext
Видимо, я довольно редко использую контекст в своих проектах, но да, useContext я отдал 4 место. Вот как он используется:
%lang(js)% const value = useContext(AnyContext);
Мы просто получаем значение контекста AnyContext и подписываемся на изменение значения этого контекста. Обратите внимание, что для работы этого хука нам необходим AnyContext.Provider где то выше по дереву компонентов. Если их будет несколько, возьмется значение ближайшего. На самом деле это все, что можно рассказать про этот хук.
- useRef
Супер крутой и многофункциональный хук. Что он делает? Бывает такое, что между рендерами нам нужно сохранять какое то значение (если в функциональном компоненте мы определим просто переменную, то она будет инициализировать каждый рендер и хранить что либо в ней у нас не получится). Вы скажете, но у нас же есть состояние? Нет, состояние тоже не подойдет, так как изменение состояния вызывает перерендер и иногда это недопустимо.
В классовых компонентах мы могли использовать свойства экземпляра класса, доступные через this. В функциональных компонентах для таких задач используется useRef.
Вот типичная задача - нам надо где то хранить количество рендеров компонента. Если бы мы каждый рендер прибавляли 1 к какому либо состоянию, созданному через useState, мы, очевидно, получили бы бесконечный цикл перерендеринга, потому что обновление состояния вызывало бы перерендер, перерендер добавлял бы нам единичку в количество рендеров, опять происходил бы инкремент состояния и так по кругу. Если же для такой задачи использовать переменную, объявленную в теле функционального компонента, то каждый перерендер она будет сбрасываться. Конечно, можно использовать переменную, объявленную вне тела функционального компонента, но это плохой подход. Единственное правильное решение такой задачи при использовании функциональных компонентов - useRef.
%lang(js)% const ref = useRef(initialValue);
Теперь значение будет сохраняться между рендерами и его изменение не будет вызывать перерендер, но есть один важный момент: читать такое значение, а также писать в него нужно таким образом:
%lang(js)% ref.current = 'new value'; console.log(new.current)
Да, наше значение находится на самом деле в поле current. Это важно помнить.
Если Вы, например, хотите контролировать перерисовку в зависимости от того поменяло значение вашего рефа или нет, то в deps можно указать ref (все будет работать, несмотря на то, что ref на самом деле контейнер для значения в поле current).
Уже трудно назвать хук бесполезным, не правда ли? Но он может еще кое что. Мы выяснили, что это просто контейнер для какого-то значения, окей. Так значит туда можно положить и ссылку на DOM узел? Конечно!
%lang(js)% import React, { useRef, useCallback } from 'react'; const Input = () => { const ref = useRef(null); const onClick = useCallback(() => { // в ref.current будет лежать ссылка на DOM узел <input /> // у которого есть метод focus, использующийся для установки фокуса // на это поле ввода ref.current.focus(); }, [ref]); return ( <div> <input ref={ref} type="text" /> <button onClick={onClick}>Focus input</button> </div> ); }
Данный компонент - это текстовое поле, но рядом с ним находится еще кнопка, при нажатии на которую на текстовое поле установится фокус.
Обратите внимание, что мы обновим наш хендлер клика, если вдруг значение ref поменяется.
Думайте о useRef как о штуке, которая возвращает вам контейнеры, в которых Вы можете хранить что угодно.
- useMemo
Вот мы и дошли до useMemo. Долго выбирал, кто пойдет первее: этот или useReducer. В итоге решил, что этот (монетку кинул).
Все очень просто, если вы знаете что такое мемоизация. Для тех, кто знает, можно пропустить блок с цитатой.
Мемоизация - сохранение результатов выполнения функций для предотвращения повторных вычислений. Давайте подумаем. Мы договорились, что будем стараться использовать только чистые функции. А что такое чистая функция?
Чистая функция:
- является детерминированной
- не обладает побочными эффектами
Детерминированная функция для определенного набора аргументов всегда будет возвращать одно и то же значение. Хоть в 1, хоть в 23345 вызов.
Продолжение про мемоизацию: Мы однажды уже вычислили значение функции для какого-то набора аргументов, и мы знаем, что при таком наборе аргументов, возвращаемое значение будет всегда одно и то же. Так зачем же нам вычислять функцию еще раз? Можно ведь просто запомнить набор аргументов и поставить ему в соответствие значение функции. Потом, когда мы в следующий раз вызовем функцию с аргументами, которые мы сохранили, мы просто возьмем сохраненное значение из таблицы соответствия набор аргументов -> значение
Мемоизировать все, конечно, не стоит. Все таки все, что вы мемоизировали будет оставаться в оперативной памяти и иногда довольно долго. Однако, если есть какой-то более менее сложный (вычислительно) алгоритм, то грех не мемоизировать его.
Собственно, хук useMemo для этого и служит. Но только в качестве аргументов используется наш любимый массив deps. Вот как это работает:
%lang(js)% const veryExpensiveIncrement = (a) => { for (let i = 0; i < 100000; i += 1); return a + 1; } const memoized = useMemo( () => { veryExpensiveIncrement(a) }, [a] );
Если представить, что у нас есть форма с вводом a, то если пользователь введет, например 1, то все зависнет, но через какое то время пользователь увидит 2. Потом если введет 2, то все опять зависнет, но через какое то время пользователь увидит 3. Однако, если потом пользователь опять введет 1, то увидит 2 моментально, так как функция veryExpensiveIncrement вызвана не будет. В этом и весь прикол мемоизации.
В React это все работает еще немного круче. Вот почему: представьте, что нам от сервера прилетает большой массив объектов, который надо отобразить. Но чтобы его отобразить нам надо пройтись по нему и добавить некоторые поля в каждый объект, а потом еще отсортировать. Так зачем же нам делать это каждый рендер, если исходный массив остается тот же? Правильно, незачем. Контролировать перевычисление мы можем с помощью массива deps.
- useReducer
Проиграл подбрасывание монетки, поэтому он здесь. Полезный хук, если не удается упростить состояние. Ранее я говорил, что использовать useState с чем-то кроме примитивных значений иногда неудобно и вообще я не рекомендую.
Короче говоря, если ваше состояние вдруг оказалось сложной структуры (например, объект User, в котором у вас наверняка есть такие поля как логин, ссылка на аватарку, имя, фамилия, пол и т.д.), то useReducer справится с этим лучше, чем useState.
Можно не читать, если вы работали с redux
%lang(js)% const reducer = (state, action) => { return state; } const [state, dispatch] = useReducer(reducer, initialValue, init);
Смысл весь в том, что мы заранее описываем все "способы" того, как могут меняться данные (и называем это описание редьюсером), а потом просто говорим, какой способ выбрать. Более корректное название для "способов" - экшоны (actions).
Описывать словами такое не очень удобно, поэтому рассмотрим пример:
%lang(js)% import React, { useReducer } from 'react'; const reducer = (state, action) => { switch (action.type) { case 'toggleOnline': return { ...state, isOnline: !state.isOnline }; case 'addFriend': return { ...state, friends: state.friends + 1 }; case 'removeFriend': return { ...state, friends: state.friends - 1 }; case 'changeName': return { ...state, name: action.newName }; default: return state; } } const User = { name: 'Vasya', surname: 'Pupkin', age: 11, friends: 0, isOnline: false }; const Counter = () => { const [state, dispatch] = useReducer(reducer, User); return ( <div className="userCard"> <div className="userActions"> <button onClick={() => dispatch({ type: 'addFriend' })}> + friend </button> <button onClick={() => dispatch({ type: 'removeFriend' })}> - friend </button> <button onClick={() => dispatch({ type: 'toggleOnline' })}> toggle online </button> <button onClick={() => dispatch({ type: 'changeName', newName: 'Tolya' })}> Change name </button> </div> <pre className="userInfo"> {JSON.stringify(state, null, 2)} </pre> </div> ); }
(Обратите внимание, как я сделал обработчики событий. Лучше так не делать, я просто для примера. Так функция будет создаваться каждый перерендер. Для <button> это не так критично, но если бы мы передавали хендлер в компонент ниже, то такими действиями заставили бы обновиться и его)
На самом деле выше мы написали целый личный кабинет какого то пользователя) Комментировать на самом деле особо нечего, просто посмотрите насколько легко и непринужденно мы контроллируем такое сложное состояние. При этом мы разделили вью и логику, в компоненте у нас только JSX, а вся логика работы вынесена в редьюсер. Может быть, не так показательно на таких маленьких объектах, но объекты бывают сильно сложнее, поверьте. И тогда useReducer это очень удобно.
Важно отметить, что React гарантирует неизменность dispatch, поэтому этот метод можно не включать в deps.
Если после dispatch состояние не изменится, то Ваш компонент перерисован не будет.
Остается сказать только про 3 аргумент useReducer. Это функция init. А используется она для ленивой инициализации. Если передать 3 параметр, Ваше начальное состояние будет результатом
%lang(js)% init(initialValue)
Честно? Никогда не использовал)
- useImperativeHandle
Честно сказать, этот хук я использовал в основном для исправления архитектурных ошибок. Таких ошибок, когда компонент спроектирован неконтроллируемым, а нам нужно извне как то на него повлиять. В чем же суть этого хука?
Помните в начале статьи я обратил внимание на одно из главных отличий функциональных компонентов от классовых - наличие экземпляра. Если вы знакомы с механизмом рефов, то знаете, что можно прикрепить его не только к DOM элементу, но и к компоненту. Тогда этот реф будет ссылаться на экзмелпяра компонента, к которому его прикрепили и мы сможем вызывать какие-то внутренние методы компонента, читать внутренние поля, в том числе state. Но есть один очень важный момент: это работает только с классовыми компонентами, так как функциональный компонент не создает экземпляр, соответственно и ссылать не на что.
useImperativeHandle решает эту проблему и вот как:
%lang(js)% import React, { useRef, useImperativeHandle, forwardRef } from 'react'; const Dialog = forwardRef((props, ref) => { const ref = useRef(); useImperativeHandle(ref, () => ({ hide: () => { ref.current.classList.add('hidden'); }, show: () => { ref.current.classList.remove('hidden'); } })); return ( <div ref={ref} className="dialog" > { /* контент диалога */ } </div> ); })
Никогда так не делайте, это можно сделать гораздо проще через state, а еще лучше передавать признак открыт? через пропсы.
Посмотрите на код сверху, проще говоря, мы говорим React что то типа: у этого компонента нет экземпляра, поэтому давай ка ты на вот этот объект будешь ссылаться.
Использование компонента:
%lang(js)% const ref = useRef(null); <Dialog ref={ref} /> // через ref.current можно будет обратиться // к методам show и hide
Вот такой полезный хук. Заметьте, что для работы с ним Вам пригодится еще forwardRef, React его тоже экспортирует.
Возможно, этот хук еще как то используется, но я использовал его только в таких целях.
- useLayoutEffect
Иногда полезный хук. Все точно так же как и useEffect, но есть 2 отличия:
- useLayoutEffect не будет работать на серверной стороне, если Вы используете server side rendering. React даже скажет об этом, выкинув предупреждение
- useLayoutEffect запустится синхронно после того как React применит все изменения в DOM. Работает и обратное: все, что вы обновите внутри useLayoutEffect Вы точно увидите в следующий рендер.
Я советую использовать его, когда у вас появилось мелькание. Как будто происходит два или обновлений вместо одного. Используйте useLayoutEffect как средство сказать React что то типа: "хей, ты что то тут рендеришь, давай еще вот это порендери".
Попробуйте выполнить этот код:
%lang(js)% import React, { useState, useEffect, useLayoutEffect } from "react"; const ExmapleA = () => { const [count, setCount] = useState(1); useEffect(() => { if (count === 0) setCount(1); }, [count]); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1); return ( <div> <h1>useEffect</h1> <div>{count}</div> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> </div> ); } const ExampleB = () => { const [count, setCount] = useState(1); useLayoutEffect(() => { if (count === 0) setCount(1); }, [count]); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1); return ( <div> <h1>useLayoutEffect</h1> <div>{count}</div> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> </div> ); } const App = () => { return ( <div className="App"> <ExampleA /> <ExampleB /> </div> ); }
Установите каждый счетчик на 1. Потом нажмите минус и там и там. В случае с useEffect Вы увидите мелькание, о котором я говорил выше. На мгновение 1 превратится в 0, затем опять в 1.
Обратите внимание, что useLayoutEffect позволяет избежать такого поведения.
- useDebugValue
Наверное, Вы знаете, что при работе с React можно пользоваться расширением браузера React DevTools (вот оно для хрома)
Так вот, если Вы вызовете этот хук в своем компоненте, то во вкладке React Devtools будете видеть передаваемое значение возле вашего компонента:
%lang(js)% useDebugValue(value, [formatter])
Как Вы поняли, используется в чисто отладочных целях. Именно этот хук я ни разу не использовал потому что родной console.log отлично справляется со своими задачами :)
Кстати, этот хук принимает и второй параметр - функцию. Она занимается форматированием значения value перед выводом. Наверное, это нужно использовать, если форматирование вывода вычислительно сложное, например, какой-нибудь парсинг.
Ну вот и все! Мы рассмотрели все хуки, которые идут с React из коробки. "В чем же магия?" - спросите Вы. А магия в том, что это обычные функции и их вызов можно перенести куда угодно! Даже в другие функции. 10 хуков дано из коробки и Вы не представляете, что с этим можно делать!
Вот пример. Допустим нам в нашем приложении нужен SearchInput. Такой компонент, который играет роль ввода для поискового запроса. Ему необязательно быть контроллируемым, однако я хочу, чтобы при нажатии на enter у меня отправлялся запрос на сервер, а при нажатии на esc поле снова становилось пустым. Довольно простая задача, но можно решить ее еще проще!
Мы просто пишем вот такой хук:
%lang(js)% const useSearchInput = (onEnter) => { const [value, setValue] = useState(''); const onKeyDown = useCallback( event => { if (event.keyCode === 13) { event.preventDefault(); onEnter(query); } if (event.keyCode === 27) setQuery(''); }, [onEnter, query] ); const onChange = useCallback(event => setQuery(event.target.value), []); return { onKeyDown, value, onChange }; }
И вот так его используем:
%lang(js)% const searchInput = useSearchInput(send); <input {...searchInput} />
После этого <input> будет поддерживать весь описанный функционал!
А теперь просто представьте какой это потенциал. Какие еще хуки можно написать? Какие задачи они смогут решать. Ограничений нет!
На этой воодушевляющей ноте, пожалуй, и закончим.
Спасибо за внимание, надеюсь было полезно.