React.js. Использование хуков. Часть 1 из 3
06.08.2021
Теги: Frontend • Hook • JavaScript • React.js • Web-разработка • Теория • Функция
Хуки — это функции, с помощью которых можно «подцепиться» к состоянию и методам жизненного цикла React из функциональных компонентов. Хуки не работают внутри классов — наоборот, они дают возможность использовать React без классов. React содержит несколько встроенных хуков, таких как useState
и useEffect
. Также можно создавать собственные хуки, чтобы повторно использовать их в других своих компонентах.
Хук состояния useState()
Как нетрудно догадаться из названия, этот хук позволяет работать со состоянием, вот пример создания хука:
const [value, setValue] = useState(initValue);
Таким образом мы создали состояние, переменная value
хранит состояние, а функция setValue()
позволяет изменять значение value
. Единственный аргумент useState()
— это начальное состояние. Исходное значение аргумента используется только при первом рендере.
Давайте рассмотрим пример компонента Clicker
, который при клике на кнопку plus
увеличивает значение, а при клику на кнопку minus
уменьшает значение. Значение счетчика count
сохраняется в состоянии классового компонента.
import React from 'react'; export default class Clicker extends React.Component { state = { count: 0, }; handleMinusClick = () => { this.setState({count: this.state.count - 1}); }; handlePlusClick = () => { this.setState({count: this.state.count + 1}); }; render() { return ( <div> <button onClick={this.handleMinusClick}>minus</button> <strong>{this.state.count}</strong> <button onClick={this.handlePlusClick}>plus</button> </div> ); } }
А теперь все то же самое, но с использованием функционального компонента:
import React, {useState} from 'react'; export default function Clicker() { // сохраняем состояние в переменной count const [count, setCount] = useState(0); const handleMinusClick = () => { setCount(count - 1); }; const handlePlusClick = () => { setCount(count + 1); }; return ( <div> <button onClick={handleMinusClick}>minus</button> <strong>{count}</strong> <button onClick={handlePlusClick}>plus</button> </div> ); }
Если новое состояние вычисляется с использованием предыдущего состояния, можно передать функцию в setState()
. Функция получит предыдущее значение и должна вернуть обновлённое значение:
const [state, setState] = useState(initState); setState((prevState) => { const nextState = prevState + 1; return nextState; });
Аргумент initState
— это значение, используемое во время начального рендеринга, в дальнейшем оно не используется. Если начальное состояние является результатом дорогостоящих вычислений, можно передать функцию, которая будет выполняться только один раз при монтировании.
// функция вызывется при каждом ренедере компонента const initState = expensiveComputation(props); const [state, setState] = useState(initState);
// функция вызывается только один раз при монтировании const [state, setState] = useState(() => { const initState = expensiveComputation(props); return initState; });
Если обновить состояние хука тем же значением, что и текущее состояние, React досрочно выйдет из хука без повторного рендера дочерних элементов и запуска эффектов.
Хук эффекта useEffect
Хук используется для отслеживания рендеринга, то есть в него мы передаём функцию, которая будет срабатывать при каждом рендере, включая первый рендер. Он выполняет ту же роль, что и componentDidMount
, componentDidUpdate
и componentWillUnmount
в React-классах, объединив их в единый API. React гарантирует, что функция будет запущена только после того, как браузерный DOM уже обновился.
Рассмотрим пример компонента Clicker
, написанного в классовом и функциональном стиле:
import React from 'react'; export default class Clicker extends React.Component { state = { count: 0, }; componentDidMount() { // обновляем заголовок окна браузера document.title = `Текущее значение: ${this.state.count}`; } componentDidUpdate() { // обновляем заголовок окна браузера document.title = `Текущее значение: ${this.state.count}`; } render() { return ( <div> <button onClick={() => this.setState({count: this.state.count - 1})}>minus</button> <strong>{this.state.count}</strong> <button onClick={() => this.setState({count: this.state.count + 1})}>plus</button> </div> ); } }
import React, {useState, useEffect} from 'react'; export default function Clicker() { // сохраняем состояние в переменной count const [count, setCount] = useState(0); // по аналогии с componentDidMount и componentDidUpdate useEffect(() => { // обновляем заголовок окна браузера document.title = `Текущее значение: ${count}`; }); return ( <div> <button onClick={() => setCount(count - 1)}>minus</button> <strong>{count}</strong> <button onClick={() => setCount(count + 1)}>plus</button> </div> ); }
Обратите внимание, что нам приходится дублировать код в классовом компоненте в методах componentDidMount()
и componentDidUpdate()
. Это потому, что во многих случаях мы хотим выполнять одни и те же действия вне зависимости от того, был ли компонент смонтирован или обновлён. Мы хотим, чтобы они выполнялись после каждого рендера — но у классовых компонентов нет таких встроенных методов. При использовании хука useEffect()
такой проблемы нет — функция вызывается при каждом рендере.
Одно из назначений метода componentDidMount()
в классовых компонентах — выполнить подписку на какой-нибудь внешний источник данных. В этом случае нужно обязательно отписаться в методе componentWillUnmount()
, чтобы не случилось утечек памяти. Во пример из документации, который это демонстрирует:
import React from 'react'; class FriendStatus extends React.Component { constructor(props) { super(props); this.state = {isOnline: null}; } componentDidMount() { ChatAPI.subscribeToFriendStatus(this.props.friend.id, this.handleStatusChange); } componentWillUnmount() { ChatAPI.unsubscribeFromFriendStatus(this.props.friend.id, this.handleStatusChange); } handleStatusChange = (status) => { this.setState({isOnline: status.isOnline}); } render() { if (this.state.isOnline === null) { return 'Loading...'; } return this.state.isOnline ? 'Online' : 'Offline'; } }
import React, {useState, useEffect} from 'react'; function FriendStatus(props) { const [isOnline, setIsOnline] = useState(null); useEffect(() => { const handleStatusChange = (status) => setIsOnline(status.isOnline); // оформяем подписку на внешний источник данных ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); // отказываемся от подписки на внешний источник return () => ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); }); if (isOnline === null) { return 'Loading...'; } return isOnline ? 'Online' : 'Offline'; }
Зачем мы вернули функцию из нашего эффекта? Это необязательный механизм, который позволяет убрать за собой перед тем, как компонент будет размонтирован. Это даёт нам возможность объединить вместе логику оформления и отмены подписки — потому как это две части единого целого. Но тут есть важное отличие от классовых компонентов — функция, которую возвращает return
, будет вызываться при каждом рендере. На самом деле, это поведение позволяет избежать багов и этим можно управлять в случае необходимости.
Вернемся к примеру из документации. Наш класс FriendStatus
подписывается на статус друга после того, как компонент смонтировался, и отписывается во время размонтирования. Но что же произойдёт, если проп friend
поменяется, пока компонент все ещё находится на экране? Наш компонент будет отображать статус в сети уже какого-нибудь другого друга. Это также может привести к утечке памяти или вообще к вылету нашего приложения при размонтировании, так как метод отписки будет использовать неправильный идентификатор.
В классовом компоненте нам бы пришлось добавить componentDidUpdate()
, чтобы решить эту задачу:
import React from 'react'; class FriendStatus extends React.Component { constructor(props) { super(props); this.state = {isOnline: null}; } componentDidMount() { ChatAPI.subscribeToFriendStatus(this.props.friend.id, this.handleStatusChange); } componentDidUpdate(prevProps) { if (prevProps.friend.id !== this.props.friend.id) { // отписаться от предыдущего friend.id ChatAPI.unsubscribeFromFriendStatus(prevProps.friend.id, this.handleStatusChange); // подписаться на следующий friend.id ChatAPI.subscribeToFriendStatus(this.props.friend.id, this.handleStatusChange); } } componentWillUnmount() { ChatAPI.unsubscribeFromFriendStatus(this.props.friend.id, this.handleStatusChange); } handleStatusChange = (status) => { this.setState({isOnline: status.isOnline}); } render() { if (this.state.isOnline === null) { return 'Loading...'; } return this.state.isOnline ? 'Online' : 'Offline'; } }
Теперь давайте рассмотрим версию этого же компонента, но уже написанного с использованием хуков — этого бага в данном компоненте нет.
// монтируем с пропсами { friend: { id: 100 } } ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // выполняем первый эффект // обновляем с пропсами { friend: { id: 200 } } ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // сбрасываем предыдущий эффект ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // выполняем следующий эффект // обновляем с пропсами { friend: { id: 300 } } ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // сбрасываем предыдущий эффект ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // выполняем следующий эффект // размонтируем компонент ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // сбрасываем последний эффект
В некоторых случаях сброс или выполнение действий при каждом рендере может вызвать проблему с производительностью. В классовых компонентах мы можем решить это, используя дополнительное сравнение prevProps
или prevState
внутри componentDidUpdate
.
Эту логику приходится использовать довольно часто, поэтому разработчики React решили встроить её в API хука useEffect()
. Можно сделать так, чтобы React пропускал вызов функции (которую передаем в useEffect()
), если определённые значения остались без изменений между рендерами. Чтобы сделать это, нужно передать массив в useEffect()
вторым необязательным аргументом.
import React, {useState, useEffect} from 'react'; export default function Clicker() { // сохраняем состояние в переменной count const [count, setCount] = useState(0); // по аналогии с componentDidMount и componentDidUpdate useEffect(() => { // обновляем заголовок окна браузера document.title = `Текущее значение: ${count}`; }, [count]); // вызывать анонимную функцию, только если count поменялся return ( <div> <button onClick={() => setCount(count + 1)}>minus</button> <strong>{count}</strong> <button onClick={() => setCount(count + 1)}>plus</button> </div> ); }
import React, {useState, useEffect} from 'react'; function FriendStatus(props) { const [isOnline, setIsOnline] = useState(null); useEffect(() => { const handleStatusChange = (status) => setIsOnline(status.isOnline); // оформяем подписку на внешний источник данных ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); // отказываемся от подписки на внешний источник return () => ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); }, [props.friend.id]); // повторно подписаться, только если props.friend.id изменился if (isOnline === null) { return 'Loading...'; } return isOnline ? 'Online' : 'Offline'; }
Если нужно запустить эффект и сбросить его только один раз (при монтировании и размонтировании), можно передать пустой массив вторым аргументом. React посчитает, что эффект не зависит от каких-либо значений из пропсов или состояния и поэтому не будет выполнять повторных запусков эффекта.
Хук хранилища useRef()
Этот хук возвращает изменяемый объект, свойство current
которого инициализируется переданным аргументом. Возвращённый объект будет сохраняться в течение всего времени жизни компонента. В целом useRef()
похож на useState()
, только без функции для изменения и доступ к значению происходит через свойство current
.
const refContainer = useRef(initValue);
Обычный случай использования — это доступ к дочернему DOM-элементу:
import React, {useRef} from 'react'; export default function Form() { const emailInputElem = useRef(null); const handleSubmit = (event) => { if (emailInputElem.current.value.trim() === "") { event.preventDefault(); // отменяем отправку формы emailInputElem.current.style.backgroundColor = "#fdd"; // красный фон emailInputElem.current.focus(); // устанавливаем фокус для ввода email } } return ( <form onSubmit={handleSubmit}> <input type="text" name="email" ref={emailInputElem} /> <input type="submit" value="Отправить" /> </form> ); }
Но хук useRef()
полезен не только этим. Он удобен для сохранения любого изменяемого значения, по аналогии с тем, как мы используем поля экземпляра в классовых компонентах. Единственная разница между useRef()
и просто созданием самого объекта — это то, что хук выдает один и тот же объект при каждом рендере.
Имейте в виду, что useRef()
не уведомляет об изменении своего содержимого. И изменение свойства current
не вызывает повторный рендер.
В названии хука React.useRef()
прослеживается связь с React.createRef()
и специальным атрибутом ref
(см. здесь). React.createRef()
создает новый объект при каждом новом рендере. А React.useRef()
создает объект только один раз при первом вызове.
import React from 'react'; export default function App() { const [state, setState] = React.useState(0); const htmlElemOne = React.createRef(null); const htmlElemTwo = React.useRef(null); console.log('React.createRef перед рендером', htmlElemOne); console.log('React.useRef перед рендером', htmlElemTwo); React.useEffect(() => { console.log('React.createRef после рендера', htmlElemOne); console.log('React.useRef после рендера', htmlElemTwo); }); return ( <div> <p id="one" ref={htmlElemOne}>Первый html элемент</p> <p id="two" ref={htmlElemTwo}>Второй html элемент</p> <button onClick={() => setState(Math.random())}>Новый рендер</button> </div> ); }
React.createRef перед рендером {current: null}
React.useRef перед рендером {current: null}
React.createRef после рендера {current: p#one}
React.useRef после рендера {current: p#two}
Нажата кнопка «Новый рендер»
React.createRef перед рендером {current: null}
React.useRef перед рендером {current: p#two}
React.createRef после рендера {current: p#one}
React.useRef после рендера {current: p#two}
Попробуйте последовательно посмотреть результат при использовании createRef
и useRef
:
import React from 'react'; export default function Form() { const [state, setState] = React.useState(0); const htmlElement = React.createRef(null); // const htmlElement = React.useRef(null); console.log('htmlElement перед рендером', htmlElement); React.useEffect(() => { // при использовании useRef это отработает один раз при первом рендере // при использовании createRef это отрабатывает при каждом новом рендере console.log('htmlElement после рендера', htmlElement); }, [htmlElement]); return ( <div> <p ref={htmlElement}>Какой-то html элемент</p> <button onClick={() => setState(Math.random())}>Новый рендер</button> </div> ); }
Давайте перепишем компонент Clicker
с кнопками plus
и minus
, чтобы не использовать хук useState()
, а обойтись только useRef()
— практического смысла в этом нет, но станет лучше понятно назначение хука — как хранилища данных.
import React, {useState} from 'react'; export default function Clicker(props) { // сохраняем состояние в переменной count const [count, setCount] = useState(0); const handleMinusClick = () => { setCount(count - 1); }; const handlePlusClick = () => { setCount(count + 1); }; return ( <div> <button onClick={handleMinusClick}>minus</button> <strong>{count}</strong> <button onClick={handlePlusClick}>plus</button> </div> ); }
import React, {useRef} from 'react'; export default function Clicker(props) { // сохраняем состояние счетчика const countValue = useRef(0); // ссылка на DOM-элемент strong const countElement = useRef(null); const handleMinusClick = () => { countValue.current = countValue.current - 1; // изменение свойства current объекта countVualue не вызывает новый рендер, // поэтому через ссылку на DOM-узел <strong> устанавливаем новый текст узла countElement.current.textContent = countValue.current; }; const handlePlusClick = () => { countValue.current = countValue.current + 1; // изменение свойства current объекта countVualue не вызывает новый рендер, // поэтому через ссылку на DOM-узел <strong> устанавливаем новый текст узла countElement.current.textContent = countValue.current; }; return ( <div> <button onClick={handleMinusClick}>minus</button> <strong ref={countElement}>{countValue.current}</strong> <button onClick={handlePlusClick}>plus</button> </div> ); }
Давайте проверим, что значение сохраняется между рендерами. Для этого из родительского компонента будем передавать props
, которые мы можем менять. Изменение пропсов будет вызывать рендер компонента Clicker
— но значение countValue.current
при этом не теряется.
export default class App extends React.Component { state = { rand: 0 } render() { return ( <div className="App"> <Clicker rand={this.state.rand} /> <button onClick={() => this.setState({rand: Math.random()})}>change</button> </div> ); } }
Пример использования хуков
На странице есть три кнопки — «Старт», «Стоп» и «Сбросить». Кнопка «Старт» запускает таймер, который каждую секунду увеличивает счетчик. Кнопка «Стоп» появляется на месте кнопки «Старт» и позволяет остановить таймер. Чтобы не потерять значение счетчика при перезагрузке страницы — значение сохраняется в localStorage
. Кнопка «Сбросить» останавливает таймер и сбрасывает значение счетчика, так что можно запустить с нуля.
import React from 'react'; import Timer from './components/Timer'; class App extends React.Component { state = { isTimer: false } handleClick = () => { this.setState(prevState => ({isTimer: !prevState.isTimer})) } render() { return ( <div className="App"> <h1>React App</h1> <button onClick={this.handleClick}>Mount and unmount Timer</button> {this.state.isTimer && <Timer />} </div> ); } }
import React from 'react'; export default class Timer extends React.Component { state = { count: 0, isCounting: false, }; timerId = null; componentDidMount() { // при монтировании получаем сохраненное значение count let reactCountValue = localStorage.getItem("reactCountValue"); if (reactCountValue !== null) { this.setState({count: parseInt(reactCountValue)}); } } componentDidUpdate(prevProps, prevState) { // при нажатии кнопок запускаем или останавливаем таймер if (this.state.isCounting !== prevState.isCounting) { this.state.isCounting ? this.startTimer() : this.stopTimer(); } // сохраняем значение счетчика на случай перезагрузки страницы localStorage.setItem("reactCountValue", this.state.count); } componentWillUnmount() { // останавливаем таймер, чтобы избежать утечки памяти this.stopTimer(); } handleStart = () => { this.setState({isCounting: true}); } handleStop = () => { this.setState({isCounting: false}); } handleReset = () => { this.setState({isCounting: false, count: 0}); } startTimer = () => { if (this.timerId === null) { this.timerId = setInterval( () => this.setState(prevState => ({count: prevState.count + 1})), 1000 ); } } stopTimer = () => { if (this.timerId !== null) { clearInterval(this.timerId); this.timerId = null; } } render() { return ( <div> <h2>React Timer</h2> <h3>{this.state.count}</h3> {!this.state.isCounting ? ( <button onClick={this.handleStart}>Start</button> ) : ( <button onClick={this.handleStop}>Stop</button> )} <button onClick={this.handleReset}>Reset</button> </div> ); } }
Перепишем этот компонент с использованием хуков useState
, useEffect
и useRef
:
import React, {useState, useEffect, useRef} from 'react'; export default function Timer() { const getCountValue = () => { const reactCountValue = localStorage.getItem("reactCountValue"); return reactCountValue ? parseInt(reactCountValue) : 0; } const [count, setCount] = useState(getCountValue()); const [isCounting, setIsCounting] = useState(false); const timerIdRef = useRef(null); const wasMounted = useRef(false); // при нажатии кнопок запускаем или останавливаем таймер useEffect(() => { // эта функция запускается после каждого рендера, в том числе после первого; // но нам не нужно запускать ее при первом ренедере (хотя вреда в этом нет) if (wasMounted.current) { if (isCounting) { startTimer(); } } return stopTimer; }, [isCounting]); // сохраняем значение счетчика на случай перезагрузки страницы useEffect(() => { // эта функция запускается после каждого рендера, в том числе после первого; // но нам не нужно запускать ее при первом ренедере (хотя вреда в этом нет) if (wasMounted.current) { localStorage.setItem("reactCountValue", count); } }, [count]); // сработает только при монтировании и размонтировании компонента useEffect(() => { // при монтировании не нужно получать сохраненное значение count, // мы уже сделали это раньше, так что эта функция почти пустая wasMounted.current = true; return () => { // при размонтировании останавливаем таймер, чтобы избежать утечек памяти if (isCounting) { setIsCounting(false); } } }, []); const handleStart = () => { setIsCounting(true); }; const handleStop = () => { setIsCounting(false); }; const handleReset = () => { setCount(0); setIsCounting(false); }; // запускает таймер const startTimer = () => { if (timerIdRef.current === null) { timerIdRef.current = setInterval(() => { setCount(prevCount => prevCount + 1); }, 1000); } }; // останавливает таймер const stopTimer = () => { if (timerIdRef.current !== null) { clearInterval(timerIdRef.current); timerIdRef.current = null; } }; return ( <div> <h2>React Timer</h2> <h3>{count}</h3> {!isCounting ? ( <button onClick={handleStart}>Start</button> ) : ( <button onClick={handleStop}>Stop</button> )} <button onClick={handleReset}>Reset</button> </div> ); }
Поиск: Hook • JavaScript • React.js • Web-разработка • Frontend • Теория • Функция • Хук • useState • useEffect • useRef