React.js. Использование хуков. Часть 1 из 3

06.08.2021

Теги: FrontendHookJavaScriptReact.jsWeb-разработкаТеорияФункция

Хуки — это функции, с помощью которых можно «подцепиться» к состоянию и методам жизненного цикла 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 (см. здесь). При этом назначение хука — хранение любых значений в течение всего времени жизни компонента. А получение и хранение ссылки на DOM-элемент — скорее, использование хука не по назначению. Зачем, если есть React.createRef()?

import React from 'react';

export default function Form() {
    const emailInputElem = React.createRef();

    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>
    );
}

Если только с помощью React.useRef() получить ссылку один раз при монтировании — в этом был бы какой-то смысл. Но тогда придется продумать логику, как получить ссылку на DOM-элемент только один раз. По мне, так овчинка выделки не стоит — код уже получается запутанным. А будет ли какая экономия ресурсов при таком подходе — еще большой вопрос.

import React from 'react';

export default function Form() {
    const emailInputElem = React.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}>
            {!emailInputElem.current ? (
                <input type="text" name="email" ref={emailInputElem} />
            ) : (
                <input type="text" name="email" />
            )}
            <input type="submit" value="Отправить" />
        </form>
    );
}

Давайте перепишем компонент 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

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.