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

15.08.2021

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

Правила хуков

Нельзя вызывать хуки внутри циклов, условных операторов или вложенных функций. Хуки нужно вызывать только внутри React-функций, до возврата какого-либо значения из них. Исполнение этого правила гарантирует, что хуки вызываются в одинаковой последовательности при каждом рендере компонента. Это позволит React правильно сохранять состояние хуков между множественными вызовами useState и useEffect.

Нельзя вызывать хуки из обычных js-функций, но можно:

  • Вызывать хуки из функционального компонента
  • Вызывать хуки из пользовательского хука

Следуя этим правилам, можно гарантировать, что вся логика состояния компонента чётко видна из исходного кода.

Пользовательские хуки

Иногда нужно повторно использовать одинаковую логику состояния в нескольких компонентах. Когда мы хотим, чтобы две js-функции разделяли какую-то логику, мы извлекаем её в третью функцию. Компоненты и хуки являются функциями, поэтому с ними это тоже работает — можно извлечь логику в пользовательский хук и использовать в нескольких компонентах.

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

1. Пользовательский хук usePrevious

Давайте рассмотрим простой пример счетчика Counter:

import React from 'react';

export default function Counter() {
    const [count, setCount] = React.useState(0);
  
    return (
        <div>
            <h1>Значение счетчика: {count}</h1>
            <button onClick={() => setCount(count => count + 1)}>Увеличить</button>
        </div>
    );
}

Допустим, нам понадобилось использовать предыдущее значение счетчика:

import React from 'react';

export default function Counter() {
    const [count, setCount] = React.useState(0);

    const prevCountRef = React.useRef();
    React.useEffect(() => {
        prevCountRef.current = count;
    });
    const prevCount = prevCountRef.current;

    return (
        <div>
            <h1>Значение счетчика: {count}, до этого: {prevCount}</h1>
            <button onClick={() => setCount(count => count + 1)}>Увеличить</button>
        </div>
    );
}

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

import React from 'react';

export default function usePrevious(value) {
    const ref = React.useRef();
    React.useEffect(() => {
        ref.current = value;
    });
    return ref.current;
}
import React from 'react';
import usePrevious from './hooks/usePrevious';

export default function Counter() {
    const [count, setCount] = React.useState(0);

    const prevCount = usePrevious(count);

    return (
        <div>
            <h1>Сейчас: {count}, до этого: {prevCount}</h1>
            <button onClick={() => setCount(count => count + 1)}>Увеличить</button>
        </div>
    );
}

2. Пользовательский хук useLocalStorage

Все тот же пример компонента Counter, значение счетчика записываем localStorage, чтобы не потерять при перезагрузке страницы:

import { useState, useEffect } from 'react';

export default function Counter() {
    const getFromStorage = () => { // получить значение из localStorage
        const storage = window.localStorage.getItem('storageCountValue');
        return storage !== null ? parseInt(storage) : 0;
    };

    const [count, setCount] = useState(getFromStorage);

    useEffect(
        () => window.localStorage.setItem('storageCountValue', count),
        [count]
    );

    return (
        <div>
            <h1>Значение счетчика: {count}</h1>
            <button onClick={() => setCount(count => count + 1)}>Увеличить</button>
        </div>
    );
}

Теперь выносим логику хранения значения счетчика в отдельный хук useLocalStorage, чтобы использовать в других компонентах:

import { useState } from 'react';

function isFunction(valueOrFunction) {
    return typeof valueOrFunction === 'function';
}

export function useLocalStorage(key, initValue) {
    const [storedValue, setStoredValue] = useState(() => {
        try {
            // если значение есть в localStorage
            const item = window.localStorage.getItem(key);
            if (item) {
                return JSON.parse(item);
            }
            // если значения нет в localStorage
            const evaluated = isFunction(initValue)
                ? initValue()
                : initValue;
            window.localStorage.setItem(key, JSON.stringify(evaluated));
            return evaluated;
        } catch (error) {
            console.error(error);
        }
    });

    const setValue = (newValue) => {
        try {
            const evaluated = isFunction(newValue) ? newValue(storedValue) : newValue;
            window.localStorage.setItem(key, JSON.stringify(evaluated));
            setStoredValue(evaluated);
        } catch (error) {
            console.error(error);
        }
    };

    return [storedValue, setValue];
}
import useLocalStorage from '../hooks/useLocalStorage';

export default function Counter() {
    const [count, setCount] = useLocalStorage('storageCountValue', 0);

    return (
        <div>
            <h1>Значение счетчика: {count}</h1>
            <button onClick={() => setCount(count => count + 1)}>Увеличить</button>
        </div>
    );
}

3. Пользовательский хук usePageBottom

Допустим, нам нужно знать, когда страница прокручена вниз до конца — например, при разработке лендинга, чтобы сделать предложение заинтересованному пользователю.

import { useSate, useEffect } from 'react';

export default function usePageBottom() {
    const [isBottom, setIsBottom] = useState(false);

    const handleScroll = () => {
        // полная высота html-документа, включая скрытую за границами окна браузера
        const documentHeight = document.documentElement.scrollHeight;
        // сколько уже прокручено от начала документа + ширина видимого окна браузера
        const currentHeight = window.pageYOffset + document.documentElement.clientHeight;
        // страница уже прокручена до конца или еще нет?
        const isPageBottom = Math.abs(documentHeight - currentHeight) < 10;
        // чаще всего мы будем обновлять состояние isBottom тем же значением; в этом
        // случае не будет повторного рендера и запуска эффектов — нам это на руку
        setIsBottom(isPageBottom);
    }

    useEffect(() => {
        window.addEventListener('scroll', handleScroll);
        return () => window.removeEventListener('scroll', handleScroll);
    }, []);

    return isBottom;
}
import { useRef, useEffect } from 'react';
import usePageBottom from '../hooks/usePageBottom';

export default function Example() {
    const isBottom = usePageBottom();
    const wasShown = useRef(false);

    // функция, которую мы передаем в useEffect, вызывается при каждом новом рендере;
    // рендер запускается при изменении состояния или пропсов; пропсов у нас нет, а
    // состояние изменяется только когда страница прокручена вниз до конца
    useEffect(() => {
        if (isBottom && !wasShown.current) {
            alert('У нас для Вас выгодное предложение!');
            wasShown.current = true;
        }
    });

    return <h1>Прокрутка вниз</h1>
}

4. Пользовательский хук useCounter

Допустим, у нас есть два счетчика. У первого две кнопки «plus one» и «minus one», которые позволяют увеличить или уменьшить значение счетчика на единицу. У второго только одна кнопка «plus four», которая увеличивает значение счетчика сразу на четыре. У этих двух компонентов общее поведение, но разное представление. Было бы хорошо, если эти два компонента совместно использовали общее поведение, а сами отвечали только за представление. Раньше такую задачу можно было решить с помощью render-пропсов, но теперь есть возможность использовать хук.

import PlusMinusOneCounter from './components/PlusMinusOneCounter';
import OnlyPlusFourCounter from './components/OnlyPlusFourCounter';

export default function App() {
    return (
        <>
            <PlusMinusOneCounter initValue={10} />
            <OnlyPlusFourCounter initValue={50} />
        </>
    );
}
import { useState } from 'react';

export default function PlusMinusOneCounterOld(props) {
    const [value, setValue] = useState(props.initValue);

    const plusOne = () => setValue(prevValue => prevValue + 1);
    const minusOne = () => setValue(prevValue => prevValue - 1);

    return (
        <div className="plus-minus-one">
            <button onClick={minusOne}>minus one</button>
            <strong>{value}</strong>
            <button onClick={plusOne}>plus one</button>
        </div>
    );
}
import { useState } from 'react';

export default function OnlyPlusFourCounterOld(props) {
    const [value, setValue] = useState(props.initValue);

    const plusFour = () => setValue(prevValue => prevValue + 4);

    return (
        <div className="only-plus-four">
            <span>{value}</span>
            <button onClick={plusFour}>plus four</button>
        </div>
    );
}

Выносим общее поведение компонентов в хук useCounter:

import { useState } from 'react';

export default function useCounter(initValue = 0, offset = 1) {
    const [value, setValue] = useState(initValue);

    const increment = () => setValue(prevValue => prevValue + offset);
    const decrement = () => setValue(prevValue => prevValue - offset);

    return [value, increment, decrement];
}
import useCounter from '../hooks/useCounter';

export default function PlusMinusOneCounter(props) {
    const [value, plusOne, minusOne] = useCounter(props.initValue, 1);

    return (
        <div className="plus-minus-one">
            <button onClick={minusOne}>minus one</button>
            <strong>{value}</strong>
            <button onClick={plusOne}>plus one</button>
        </div>
    );
}
import useCounter from '../hooks/useCounter'

export default function OnlyPlusFourCounter(props) {
    const [value, plusFour] = useCounter(props.initValue, 4);

    return (
        <div className="only-plus-four">
            <strong>{value}</strong>
            <button onClick={plusFour}>plus four</button>
        </div>
    );
}

5. Пользовательский хук useMergedState

При использовании классовых компонентов мы храним состояние как объект. При обновлении состояния нам нужно передать методу setState() объект, который содержит только те поля, которые надо изменить.

import React from 'react';

export default class Form extends React.Component {
    constructor() {
        super();
        this.state = {
            name: '',
            mail: '',
        };
    }

    handleChangeName = (event) => {
        // передаем методу setState объект с полем name
        this.setState({name: event.target.value});
    };

    handleChangeMail = (event) => {
        // передаем методу setState объект с полем mail
        this.setState({mail: event.target.value});
    };

    render() {
        return (
            <form>
                <input
                    type="text"
                    name="name"
                    value={this.state.name}
                    onChange={this.handleChangeName}
                    placeholder="Имя"
                />
                <input
                    type="text"
                    name="mail"
                    value={this.state.mail}
                    onChange={this.handleChangeMail}
                    placeholder="Почта"
                />
                <input type="submit" value="Отправить" />
            </form>
        );
    }
}

При использовании функционального компонента обычно не используют объект:

import { useState } from 'react';

export default function FormFunc() {
    const [name, setName] = useState('');
    const [mail, setMail] = useState('');

    const handleChangeName = (event) => {
        setName(event.target.value);
    };

    const handleChangeMail = (event) => {
        setMail(event.target.value);
    };

    return (
        <form>
            <input
                type="text"
                name="name"
                value={name}
                onChange={handleChangeName}
                placeholder="Имя"
            />
            <input
                type="text"
                name="mail"
                value={mail}
                onChange={handleChangeMail}
                placeholder="Почта"
            />
            <input type="submit" value="Отправить" />
        </form>
    );
}

Если полей формы много, то удобнее хранить их в объекте. Но тогда при вызове setState() нам придется передавать ей объект состояния целиком, а не только объект с полями, которые изменились. В этом существенное отличие функциональных компонентов от классовых. Здесь мы передаем функции setState() не объект, а функцию, которая возвращает новый объект состояния — так тоже можно и даже рекомендуется, если следующее состояние зависит от предыдущего.

import { useState } from 'react';

const initState = {
    name: '',
    mail: ''
};

export default function Form() {
    const [state, setState] = useState(initState);

    const handleChangeName = (event) => {
        setState(prevState => {
            return {
                ...prevState,
                name: event.target.value,
            };
        });
    };

    const handleChangeMail = (event) => {
        setState(prevState => {
            return {
                ...prevState,
                mail: event.target.value,
            };
        });
    };

    return (
        <form>
            <input
                type="text"
                name="name"
                value={state.name}
                onChange={handleChangeName}
                placeholder="Имя"
            />
            <input
                type="text"
                name="mail"
                value={state.mail}
                onChange={handleChangeMail}
                placeholder="Почта"
            />
            <input type="submit" value="Отправить" />
        </form>
    );
}

Давайте создадим хук useMergedState, который позволит передавать функции setState не весь объект нового состояния целиком, а только объект, содержащий измененные свойства.

import { useState } from 'react';

function useMergedState(initState) {
    const [state, setState] = useState(initState);

    const mergeState = (changes) => {
        setState(prevState => {
            return {
                ...prevState,
                ...changes,
            };
        });
    };

    return [state, mergeState];
}
import useMergedState from '../hooks/useMergedState';

const initState = {
    name: '',
    mail: ''
};

export default function Form() {
    const [state, setState] = useMergedState(initState);

    const handleChangeName = (event) => {
        // передаем функции setState не объект состояния целиком, а только объект с полем name
        setState({name: event.target.value});
    };

    const handleChangeMail = (event) => {
        // передаем функции setState не объект состояния целиком, а только объект с полем mail
        setState({mail: event.target.value});
    };

    return (
        <form>
            <input
                type="text"
                name="name"
                value={state.name}
                onChange={handleChangeName}
                placeholder="Имя"
            />
            <input
                type="text"
                name="mail"
                value={state.mail}
                onChange={handleChangeMail}
                placeholder="Почта"
            />
            <input type="submit" value="Отправить" />
        </form>
    );
}

6. Пользовательский хук useEventListener

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

import { useEffect, useRef } from 'react';

export default function useEventListener(eventName, eventHandler, element = window) {
    // будем хранить обработчик события, пока он не изменится — считаем,
    // что вызывающий код обернул функцию eventHandler в useCallback()
    const savedHandler = useRef(eventHandler);

    // если обработчик изменился, сохраняем новое значение
    useEffect(() => {
        savedHandler.current = eventHandler;
    }, [eventHandler]);

    useEffect(() => {
        const isSupported = element && element.addEventListener;
        if (!isSupported) {
            throw new Error('addEventListener not supported by ' + element)
        }

        const eventListener = (event) => {
            if (savedHandler.current) {
                savedHandler.current(event);
            }
        }

        element.addEventListener(eventName, eventListener);

        return () => element.removeEventListener(eventName, eventListener);
    }, [eventName, element])
}

Создадим небольшое приложение, которое будет отслеживать положение указателя мыши и рисовать на этом месте красный кружок. Таким образом при перемещении указателя мыши будет отрисован трэк из кружков, показывающий путь. При нажатии кнопки Backspace этот путь стирается и все начинается сначала.

import { useCallback, useState } from 'react';
import useEventListener from '../hooks/useEventListener';

export default function MouseTrack() {
    const [coords, setCoords] = useState([]);

    const onMouseMove = useCallback((event) => {
        const point = {x: event.clientX, y: event.clientY};
        setCoords(prevValue => [...prevValue, point]);
    }, []);

    useEventListener('mousemove', onMouseMove);

    const onKeyDown = useCallback((event) => {
        if (event.key === 'Backspace') {
            setCoords([]);
        }
    }, []);

    useEventListener('keydown', onKeyDown);

    return (
        <>
            <h2>Mouse track</h2>
            {coords.map((point, index) => {
                const style = {
                    position: 'absolute',
                    left: point.x - 5,
                    top: point.y - 5,
                    backgroundColor: '#F99',
                    width: 10,
                    height: 10,
                    borderRadius: 5,
                }
                return <div key={index} style={style}></div>
            })}
        </>
    );
}

7. Пользовательский хук useWhatCausedRender

Допустим, у нас есть тяжелый компонент, который к тому же часто рендерится. Мы обернули его в React.memo(), но ренедеры все равно происходят слишком часто. Причина этого — изменение состояния или изменение пропсов. Первое определить достаточно легко, а вот второе поможет определить хук useWhatCausedRender.

import { useEffect, useRef } from 'react';

export function useWhatCausedRender(compName, nextProps) {
    const prevPropsRef = useRef({});

    useEffect(() => {
        const prevProps = prevPropsRef.current;
        // получаем ключи предыдущего и текущего пропсов
        const allKeys = Object.keys({ ...prevProps, ...nextProps });
        // перебираем ключи, чтобы найти, что изменилось
        const changes = [];
        allKeys.forEach((key) => {
            if (prevProps[key] !== nextProps[key]) {
                changes.push({
                    key,
                    old: prevProps[key],
                    new: nextProps[key],
                });
            }
        });

        if (changes.length) {
            console.log(`[${compName}] rerendered because of:`);
            changes.forEach(item => {
                console.log(`  change ${item.key}: ${item.old} => ${item.new}`);
            });
        }

        prevPropsRef.current = nextProps;
    });
}

Посмотрим на примере, как это работает:

import { useState, memo } from 'react';
import { useWhatCausedRender } from '../hooks/useWhatCausedRender';

const HeavyComponent = memo((props) => {
    const { value, style } = props;
    useWhatCausedRender('HeavyComponent', props);
    return <p style={style}>Counter: {value}</p>;
});

export default function Example() {
    const [counter, setCounter] = useState(0);
    const [state, setState] = useState(false);

    const increment = () => {
        setCounter(prev => prev + 1);
    };

    const style = {
        color: '#F00',
        backgroundColor: '#EEE',
    };

    return (
        <>
            <h1>What Caused Render</h1>
            <p>
                <button onClick={increment}>Increment counter</button>
            </p>
            <HeavyComponent value={counter} style={style} />
            <p>
                <button onClick={() => setState(!state)}>Rerender Example</button>
            </p>
        </>
      );
}
[HeavyComponent] rerendered because of: нажимаем кнопку «Increment counter»
    change value: 0 => 1
    change style: [object Object] => [object Object]
[HeavyComponent] rerendered because of: нажимаем кнопку «Rerender Example»
    change style: [object Object] => [object Object]

Мы передаем компоненту HeavyComponent, который обернут в React.memo, пропсы counter и style. По идее, только изменение counter должно вызывать рендер HeavyComponent. Но когда мы вызываем рендер компонента Example, не изменяя counter — это тоже провоцирует рендер HeavyComponent. Потому что объект style создается заново при каждом рендере Example. Исправить это легко — надо вынести style за пределы функции Example. Или использовать useMemo — который будет возвращать ссылку на один и тот же объект.

import { useState, memo } from 'react';
import { useWhatCausedRender } from '../hooks/useWhatCausedRender';

const HeavyComponent = memo((props) => {
    /* .......... */
});

const style = {
    color: '#F00',
    backgroundColor: '#EEE',
};

export default function Example() {
    /* .......... */
}
import { useState, useMemo, memo } from 'react';
import { useWhatCausedRender } from '../hooks/useWhatCausedRender';

const HeavyComponent = memo((props) => {
    /* .......... */
});

export default function Example() {
    /* .......... */
    const style = useMemo(() => ({
        color: '#F00',
        backgroundColor: '#EEE',
    }), []);
    /* .......... */
}

8. Пользовательский хук useDebounce

Допустим, мы храним какое-то значение в состоянии компонента. И это значение изменяется очень часто, тем самым вызывая многочисленные рендеры. Причем как рендеры этого компонента, так ренедеры всех потомков этого компонента. Может быть, мы такое значение не храним в состоянии, но зато передаем через пропсы другому компоненту, вызывая его многочисленные рендеры. В общем, у нас есть проблема с производительностью приложения — и надо как-то эту решить.

Хук useDebounce будет принимать на вход часто изменяемое значение и возвращать редко изменяемое значение. Если рендеры компонента будут привязаны к этому новому значению — их станет намного меньше. Хук будет изменять свое состояние только при условии, что входящее значение было стабильным достаточно долго. То есть, если входящее значение не изменяется в течение заданного интервала времени — хук изменяет значение, которое хранит в состоянии.

Часто изменяемое значение 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
Редко изменяемое значение 0 - - - - 5 - - - - 10 - - - - 15 - - - - 20
import { useEffect, useState } from 'react';

export default function useDebounce(value, delay) {
    const [debouncedValue, setDebouncedValue] = useState(value);

    // При каждом изменении значения value мы вызываем setTimeout(), который
    // изменит значение в состоянии на value через delay миллисекунд. Но если
    // до того момента будет получено новое значение value — то мы отменяем
    // изменение состояния и запускаем новый setTimeout() с тем же условием.
    // Состояние debouncedValue изменится только в том случае, когда входящее
    // значение value будет стабильным на протяжении delay миллисекунд.
    useEffect(() => {
        const timeoutHandle = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);
        return () => clearTimeout(timeoutHandle);
    }, [value, delay]);

    return debouncedValue;
}

Давайте вспомним пример с рисованием следа от указателя мыши, который мы рассматривали ранее:

import { useCallback, useEffect, useState, memo } from 'react';
import useEventListener from '../hooks/useEventListener';
import useDebounce from '../hooks/useDebounce';

const POINT_SIZE = 10;

const Point = memo((props) => {
    const { left, top, color } = props;
    const style = {
        position: 'absolute',
        left: left - POINT_SIZE / 2,
        top: top - POINT_SIZE / 2,
        width: POINT_SIZE,
        height: POINT_SIZE,
        borderRadius: POINT_SIZE / 2,
        backgroundColor: color,
    };
    return (
        <div style={style} />
    );
});

const INITIAL_POS = {
    x: 0,
    y: 0,
};

export default function Example() {
    const [lastDefaultPos, setLastDefaultPos] = useState(INITIAL_POS);
    // принимает на вход часто изменяемое значение, возвращает редко изменяемое значение
    const lastDebouncedPos = useDebounce(lastDefaultPos, 300);

    const [defaultPath, setDefaultPath] = useState([]);
    const [debouncedPath, setDebouncedPath] = useState([]);

    useEventListener(
        'mousemove',
        useCallback((event) => {
            const newDefaultPos = { x: event.clientX, y: event.clientY };
            setLastDefaultPos(newDefaultPos);
            setDefaultPath((prev) => [...prev, newDefaultPos]);
        }, [])
    );

    useEffect(() => {
        setDebouncedPath((prev) => [...prev, lastDebouncedPos]);
    }, [lastDebouncedPos]);

    return (
        <>
            <h1>Пользовательский хук useDebounce</h1>
            <p>Часто изменяемое значение: {JSON.stringify(defaultPath)}</p>
            <p>Редко изменяемое значение: {JSON.stringify(debouncedPath)}</p>
            {defaultPath.map((point, index) => (
                <Point key={index} left={point.x} top={point.y} color="#F88" />
            ))}
            {debouncedPath.map((point, index) => (
                <Point key={index} left={point.x} top={point.y} color="#88F" />
            ))}
        </>
    );
}

Дочерний компонент Point, который обёрнут в React.memo, получает через пропсы сначала часто изменяемое значение, а потом редко изменяемое значение. И во втором случае рендеров компонента Point будет намного меньше — синих точек намного меньше, чем красных.

9. Пользовательский хук useThrottle

Хук useThrottle решает ту же задачу, что и useDebounce — но иначе. Он принимает на вход часто изменяемое значение и возвращает редко изменяемое значение. Изменение значения в состоянии происходит только один раз в заданный промежуток времени. Для примера, значение изменяется 100 раз за одну секунду. Мы разбиваем секунду на 10 интервалов по 0.1 секунды каждый. Девять раз изменение входного значения пропускаем, на десятый раз — изменяем.

import { useEffect, useState, useRef } from 'react';

export default function useThrottle(value, delay) {
    const [throttledValue, setThrottledValue] = useState(value);
    const valueRef = useRef(value);

    useEffect(() => {
        valueRef.current = value;
    }, [value]);

    useEffect(() => {
        const intervalHandle = setInterval(() => {
            setThrottledValue(valueRef.current);
        }, delay);
        return () => clearInterval(intervalHandle);
    }, [delay]);

    return throttledValue;
}

Использование хука на примере рисования следа от указателя мыши в окне браузера:

import { useCallback, useEffect, useState, memo } from 'react';
import useEventListener from '../hooks/useEventListener';
import useThrottle from '../hooks/useThrottle';

const POINT_SIZE = 10;

const Point = memo((props) => {
    const { left, top, color } = props;
    const style = {
        position: 'absolute',
        left: left - POINT_SIZE / 2,
        top: top - POINT_SIZE / 2,
        width: POINT_SIZE,
        height: POINT_SIZE,
        borderRadius: POINT_SIZE / 2,
        backgroundColor: color,
    };
    return (
        <div style={style} />
    );
});

const INITIAL_POS = {
    x: 0,
    y: 0,
};

export default function Example() {
    const [lastDefaultPos, setLastDefaultPos] = useState(INITIAL_POS);
    // принимает на вход часто изменяемое значение, возвращает редко изменяемое значение
    const lastThrottledPos = useThrottle(lastDefaultPos, 300);

    const [defaultPath, setDefaultPath] = useState([]);
    const [throttledPath, setThrottledPath] = useState([]);

    useEventListener(
        'mousemove',
        useCallback((event) => {
            const newDefaultPos = { x: event.clientX, y: event.clientY };
            setLastDefaultPos(newDefaultPos);
            setDefaultPath((prev) => [...prev, newDefaultPos]);
        }, [])
    );

    useEffect(() => {
        setThrottledPath((prev) => [...prev, lastThrottledPos]);
    }, [lastThrottledPos]);

    return (
        <>
            <h1>Пользовательский хук useThrottle</h1>
            <p>Часто изменяемое значение: {JSON.stringify(defaultPath)}</p>
            <p>Редко изменяемое значение: {JSON.stringify(throttledPath)}</p>
            {defaultPath.map((point, index) => (
                <Point key={index} left={point.x} top={point.y} color="#F88" />
            ))}
            {throttledPath.map((point, index) => (
                <Point key={index} left={point.x} top={point.y} color="#88F" />
            ))}
        </>
    );
}

10. Пользовательский хук useAsync

При выполнении асинхронных операций — например, отправке запроса на сервер — считается хорошим тоном сообщать пользователю о текущем статусе операции.

import { useCallback, useState } from 'react';

export const AsyncStatus = {
    IDLE: 'idle',
    PENDING: 'pending',
    SUCCESS: 'success',
    ERROR: 'error',
};

export function useAsync(asyncFunc) {
    const [status, setStatus] = useState(AsyncStatus.IDLE);
    const [result, setResult] = useState();
    const [error, setError] = useState();

    const run = useCallback(() => {
        if (status === AsyncStatus.PENDING) {
            console.error('Still pending, cannot run again...');
            return;
        }

        setStatus(AsyncStatus.PENDING);
        asyncFunc()
            .then((res) => {
                setResult(res);
                setError(null);
                setStatus(AsyncStatus.SUCCESS);
            })
            .catch((err) => {
                setResult(null);
                setError(err);
                setStatus(AsyncStatus.ERROR);
            });
    }, [status, asyncFunc]);

    return { run, status, result, error };
}
import { useEffect, useState } from 'react';

export default function useAnimatedText(text, delayMs) {
    const [currentPos, setCurrentPos] = useState(0);

    useEffect(() => {
        const intervalHandle = setInterval(() => {
            setCurrentPos(pos => {
                const isLast = pos === text.length - 1;
                return isLast ? 0 : pos + 1;
            });
        }, delayMs);

        return () => clearInterval(intervalHandle);
    }, [text, delayMs]);

    return text.substring(0, currentPos);
}

Простое приложение, демонстрирующее использование хука:

import { useAsync, AsyncStatus } from '../hooks/useAsync';
import useAnimatedText from '../hooks/useAnimatedText';

function requestRandomNumber() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const randomNumber = Math.random() * 100;
            if (randomNumber > 50) {
                resolve(randomNumber);
            } else {
                reject('Maybe next time');
            }
        }, 3000);
    });
}

function AnimatedText({ text }) {
    const currentText = useAnimatedText(text, 50);
    return <p>{currentText}</p>;
}

export default function Example() {
    const { run, status, result, error } = useAsync(requestRandomNumber);
    return (
        <>
            <h2>Хуки useAsync и useAnimatedText</h2>
            {status === AsyncStatus.IDLE ? (
                <button onClick={run}>Request random number</button>
            ) : status === AsyncStatus.PENDING ? (
                <AnimatedText text="Request in progress..." />
            ) : status === AsyncStatus.SUCCESS ? (
                <>
                    <button onClick={run}>Request again</button>
                    <p>Current random number: {result}</p>
                </>
            ) : (
                <>
                    <button onClick={run}>Request again</button>
                    <p style={{ color: 'red' }}>Error: {error}</p>
                </>
            )}
        </>
    );
}

11. Пользовательский хук useIsMountedRef

Позволяет определить, что компонент смонтирован и с ним можно работать. Это может быть полезно при работе с асинхронными функциями. Если асинхронная функция попробует изменить state компонента, когда он уже размонтирован — будет ошибка «Can't perform a React state update on an unmounted component».

import { useEffect, useRef } from 'react';

export default function useIsMountedRef() {
    const isMountedRef = useRef(false);

    useEffect(() => {
        isMountedRef.current = true;
        return () => isMountedRef.current = false;
    }, []);

    return isMountedRef;
}

Небольшой пример, демонстрирующий использование хука. Есть компоненты Parent и Child. И есть кнопка, которая позволяет смонтировать и размонтировать Child. Компонент Child при монтировании загружает какие-то данные с сервера и изменяет свое состояние. Если до того момента, как данные будут получены, нажать кнопку — компонент Child будет размонтирован. А попытка изменить состояние размонтрованного компонента, когда пришли данные с сервера, приведет к ошибке.

import { useState, useEffect } from 'react';

const delay = (ms) => new Promise((resolve, reject) => {
    setTimeout(() => {
        if (Math.random() > 0.2) {
            resolve('Данные успешно загружены');
        } else {
            reject('Что-то пошло не так');
        }
    }, ms)
});

function Child() {
    const [data, setData] = useState('Загрузка...');

    // вызов api и изменение состояния
    useEffect(() => {
        delay(3000)
            .then(data => setData(data))
            .catch(error => setData(error))
    }, [])
    return (
        <>
            <h2>Child Component</h2>
            <p>{data}</p>
        </>
    )
}

export default function Parent() {
    const [visible, setVisible] = useState(true);

    const toggleVisibility = () => setVisible(prev => !prev);

    return (
        <>
            <h2>Parent Component</h2>
            <button onClick={toggleVisibility}>{visible ? 'Hide' : 'Show'} child</button>
            {visible && <Child />}
        </>
    )
}

Теперь используем хук, чтобы определить, можно ли изменять состояние компонента:

/* .......... */
function Child() {
    const [data, setData] = useState('З...');
    const isMountedRef = useIsMountedRef();

    // вызов api и изменение состояния
    useEffect(() => {
        delay(3000)
            .then((data) => {
                if (isMountedRef.current) setData(data);
            })
            .catch((error) => {
                if (isMountedRef.current) setData(error);
            })
    }, [isMountedRef])

    return (
        <>
            <h2>Child Component</h2>
            <p>{data}</p>
        </>
    )
}
/* .......... */

12. Пользовательский хук useWindowSize

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

import { useCallback, useEffect, useState } from 'react';
import { useEventListener } from './useEventListener';

const INITIAL_SIZE = [0, 0];

export function useWindowSize() {
    const [size, setSize] = useState(INITIAL_SIZE);

    useEffect(() => {
        const { innerWidth, innerHeight } = window;
        setSize([innerWidth, innerHeight]);
    }, []);

    useEventListener(
        'resize',
        useCallback((event) => {
            const { innerWidth, innerHeight } = event.target;
            setSize([innerWidth, innerHeight]);
        }, []);
    );

    return size;
}

13. Пользовательский хук useHistory

Хук позволяет хранить историю изменения какого-либо значения в массиве, сам массив сохраняется в состоянии хука.

import { useEffect, useState } from 'react';

export function useHistory(newValue) {
    const [history, setHistory] = useState([]);

    useEffect(() => {
        setHistory(prevValues => [...prevValues, newValue]);
    }, [newValue]);

    return history;
}
import { useState } from 'react';
import { useHistory } from '../hooks/useHistory';

export default function Example() {
    const [random, setRandom] = useState(0);
    const history = useHistory(random);

    const newRandomNumber = () => {
        setRandom(Math.round(Math.random() * 100));
    }

    return (
        <>
            <p>Случайные числа: {history.join(',')}</p>
            <button onClick={newRandomNumber}>Новое случайное число</button>
        </>
    );
}

14. Пользовательский хук useElementSize

Для правильной работы некоторых компонентов нужно знать размеры элементов на странице, чтобы правильно их позиционировать и изменять размеры. Можно сказать, что этот хук дополняет хук useWindowSize — он тоже должен отслеживать событие resize объекта window — и возвращать новые размеры.

import { useCallback, useEffect, useState } from 'react';
import { useEventListener } from '../hooks/useEventListener';

const DEFAULT_SIZE = {
    width: 0,
    height: 0,
};

export function useElementSize(elementRef) {
    const [size, setSize] = useState(DEFAULT_SIZE);

    const updateElementSize = useCallback(() => {
        const node = elementRef.current;
        if (node) {
            const { width, height } = node.getBoundingClientRect();
            setSize({ width, height });
        }
    }, [elementRef]);

    useEffect(() => {
        updateElementSize();
    }, [updateElementSize]);

    useEventListener('resize', updateElementSize);

    return size;
}

15. Пользовательский хук useHovered

Хук позволяет определить, когда указатель мыши находится над элементом страницы.

import { useEffect, useState } from 'react';

export default function useHovered(elementRef) {
    const [hovered, setHovered] = useState(false);

    useEffect(() => {
        if (!elementRef.current) return;

        const handleMouseOver = () => {
            setHovered(true);
        };

        const handleMouseOut = () => {
            setHovered(false);
        };

        const node = elementRef.current;
        node.addEventListener('mouseover', handleMouseOver);
        node.addEventListener('mouseout', handleMouseOut);

        return () => {
            node.removeEventListener('mouseover', handleMouseOver);
            node.removeEventListener('mouseout', handleMouseOut);
        };
    }, [elementRef]);

    return hovered;
}
import { useRef } from 'react';
import useHovered from '../hooks/useHovered';

const style = {
    fontSize: 128,
};

function Smile() {
    const spanRef = useRef();
    const isHovered = useHovered(spanRef);

    return (
        <span ref={spanRef} style={style}>
            {isHovered ? '\u{1F604}' : '\u{1F642}'}
        </span>
    );
}

export default function Example() {
    const array = [];
    for (let i = 0; i < 5; i++) {
        array.push(<Smile key={i} />);
    }

    return (
        <>
            <h2>Пользовательский хук useHovered</h2>
            {array}
        </>
    );
}

16. Пользовательский хук useInterval

Хук позволяет запустить таймер и выполнять функцию callback с интервалом delay. Остановить таймер можно, если передать вторым параметром null.

import { useEffect, useRef } from 'react'

function useInterval(callback, delay) {
    const savedCallback = useRef(callback);

    useEffect(() => {
        savedCallback.current = callback;
    }, [callback])

    useEffect(() => {
        if (delay === null) return;
        const id = setInterval(() => savedCallback.current(), delay);
        return () => clearInterval(id);
    }, [delay])
}

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

import { useEffect } from 'react'

function useInterval(callback, delay) {
    useEffect(() => {
        if (delay === null) return;
        const id = setInterval(() => callback(), delay);
        return () => clearInterval(id);
    }, [callback, delay])
}

Но в этой реализации при изменении ссылки на callback-функцию сперва отработает сброс эффекта (то есть будет вызов clearInterval), а потом произойдет запуск нового таймера setInterval, которой первым параметром получит новую анонимную функцию, которая будет вызывать новую callback-функцию.

В первой реализации при изменении ссылки на callback-функцию не будет вызова clearInterval и таймер не будет запущен снова (не будет нового вызова setInterval) — продолжит работать «старый» таймер, но при запуске анонимной функции будет вызываться уже новая callback-функция. То есть, ссылка на callback-функцию заменяется «на лету», без необходимости сброса «старого» таймера.

17. Пользовательский хук useTimeout

Хук позволяет выполнять функцию callback с задержкой delay. Отменить выполнение можно, если передать вторым параметром null.

import { useEffect, useRef } from 'react'

export default function useTimeout(callback, delay) {
    const savedCallback = useRef(callback);

    useEffect(() => {
        savedCallback.current = callback;
    }, [callback])

    useEffect(() => {
        if (delay === null) return;
        const id = setTimeout(() => savedCallback.current(), delay);
        return () => clearTimeout(id);
    }, [delay])
}

18. Пользовательский хук useUpdateEffect

Этот хук работает точно так же, как и useEffect, но эффект не запускается при первом рендере компонента.

import { useEffect, useRef } from 'react';

export default function useUpdateEffect(callEffect, deps) {
    const firstRender = useRef(true);

    useEffect(() => {
        let undoEffect;
        if (firstRender.current) {
            firstRender.current = false;
        } else {
            undoEffect = callEffect();
        }
        return undoEffect;
    }, [deps]);
}

Поиск: Hook • JavaScript • React.js • Frontend • Web-разработка • Теория • Функция • Хук • usePrevious • useLocalStorage

Каталог оборудования
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.