React.js. Использование хуков. Часть 2 из 3
11.08.2021
Теги: Frontend • Hook • JavaScript • React.js • Web-разработка • Теория • Функция
Хук контекста useContext
const value = useContext(MyContext);
Принимает объект контекста (значение, возвращённое из React.createContext
) и возвращает текущее значение контекста для этого контекста. Текущее значение контекста определяется пропом value
ближайшего <MyContext.Provider>
над вызывающим компонентом в дереве.
Когда ближайший <MyContext.Provider>
над компонентом обновляется, этот хук вызовет повторный рендер с последним значением контекста. Даже если родительский компонент использует React.memo
или реализует shouldComponentUpdate
, то повторный рендер будет выполняться, начиная c компонента, использующего useContext
.
import React, {useContext} from 'react'; // Контекст позволяет передавать значение глубоко в дерево компонентов без передачи пропсов // на каждом уровне. Создадим контекст для текущей темы со значением «light» по умолчанию. const ThemeContext = React.createContext('light'); export default class App extends React.Component { render() { return ( <ThemeContext.Provider value="dark"> <Toolbar /> </ThemeContext.Provider> ); } } class Toolbar extends React.Component { render() { return <Button /> } } function Button() { const theme = useContext(ThemeContext); return <button className={theme}>Button</button> }
.dark {
background-color: #333;
color: #fff;
border: 2px solid #333;
}
.light {
background-color: #eee;
color: #333;
border: 2px solid #eee;
}
Давайте рассмотрим пример посложнее, чтобы лучше понять использование хука. У нас будет два контекста — CatalogContext
и BasketContext
. Первый будет хранить товары каталога, а второй отвечать за корзину покупателя. Мы обернем верхний элемент <App/>
компонентами <CatalogContextProvider>
и <BasketContextProvider>
— так что все потомки будут иметь доступ к двум контекстам. Доступ возможен либо через <CatalogContext.Consumer>
и <BasketContext.Consumer>
, либо через useContext(CatalogContext)
и useContext(BasketContext)
.
[src] App.js [components] CatalogContext.js BasketContext.js Content.js Catalog.js Basket.js
import React from 'react'; import Content from './components/Content'; import {CatalogContextProvider} from './components/CatalogContext'; import {BasketContextProvider} from './components/BasketContext'; export default function App() { return ( <CatalogContextProvider> <BasketContextProvider> <Content /> </BasketContextProvider> </CatalogContextProvider> ); }
import React from 'react'; export const CatalogContext = React.createContext(); const products = [ {id: 123, title: 'JavaScript', price: 567.00}, {id: 456, title: 'React.js', price: 678.00}, {id: 789, title: 'Node.js', price: 789.00}, ]; export function CatalogContextProvider(props) { return ( <CatalogContext.Provider value={products}> {props.children} </CatalogContext.Provider> ) }
import React, {useState} from 'react'; export const BasketContext = React.createContext(); export function BasketContextProvider(props) { const [products, setProducts] = useState([]); const add = (item) => { if (!products.includes(item)) { setProducts([item, ...products]); } }; const remove = (item) => { setProducts(products.filter(product => product !== item)); }; const cost = () => { return products.reduce((cost, product) => cost + product.price, 0); }; const clear = () => { setProducts([]); } const context = { products: products, add: add, remove: remove, cost: cost, clear: clear, }; return ( <BasketContext.Provider value={context}> {props.children} </BasketContext.Provider> ) }
import React from 'react'; import Basket from './Basket'; import Catalog from './Catalog'; export default function Content() { return ( <div> <Basket /> <Catalog /> </div> ); }
import {useContext} from 'react'; import {CatalogContext} from './CatalogContext'; import {BasketContext} from './BasketContext'; export default function Catalog() { const products = useContext(CatalogContext); // товары каталога const basket = useContext(BasketContext); // корзина покупателя return ( <div> <h1>Каталог</h1> <table border="1" cellSpacing="0" cellPadding="5"> <tr> <th>Код</th> <th>Наименование</th> <th>Цена</th> <th>В корзину</th> </tr> {products.map(product => ( <tr key={product.id}> <td>{product.id}</td> <td>{product.title}</td> <td>{product.price}</td> <td> <button onClick={() => basket.add(product)}>В корзину</button> </td> </tr> ))} </table> </div> ); }
import {useContext} from 'react'; import {BasketContext} from './BasketContext'; export default function Basket() { const basket = useContext(BasketContext); return ( <div> <h3>Корзина</h3> <table border="1" cellSpacing="0" cellPadding="5"> <tr> <th>Код</th> <th>Наименование</th> <th>Цена</th> <th>Удалить</th> </tr> {basket.products.map(product => ( <tr key={product.id}> <td>{product.id}</td> <td>{product.title}</td> <td>{product.price}</td> <td> <button onClick={() => basket.remove(product)}>Удалить</button> </td> </tr> ))} <tr> <td colSpan="2">Сумма</td> <td>{basket.cost()}</td> <td> <button onClick={() => basket.clear()}>Очистить</button> </td> </tr> </table> </div> ); }
И вот что у нас получилось в итоге. Есть каталог товаров и есть корзина — товары можно как добавлять в корзину, так и удалять из нее. Причем доступ к товарам каталога и к корзине через контекст есть у любого компонента приложения, который находится ниже <App/>
.
Хук эффекта useLayoutEffect
Хук useEffect()
в функциональных компонентах выполняет ту же роль, что и componentDidMount()
, componentDidUpdate()
и componentWillUnmount()
в классовых компонентах. Но тут важно понимать, что есть существенное отличие в работе этой функции и методов жизненного цикла.
Например, если внутри componentDidMount()
мы вызываем метод setState()
, это приводит к повторному рендеру. Но пользователь не увидит промежуточное состояние между рендерами — изменения будут отправлены в браузерный DOM только после второго рендера. С другой стороны, если в useEffect()
при монтировании компонента изменить состояние, то изменения в браузерный DOM будут отправлены дважды — после каждого рендера.
Такое поведение useEffect()
иногда может вызывать неприятный эффект мерцания для пользователей приложения. Исправить это можно, если использовать хук useLayoutEffect()
вместо useEffect()
— тогда промежуточное состояние пользователь не увидит. Изменения будут отправлены в браузерный DOM только один раз, после второго рендера.
useLayoutEffect()
только в случае острой необходимости, чтобы вдруг не возникло проблем с правильным рендерингом компонентов.
Давайте рассмотрим небольшой пример, где реализуем классовый и функциональный комоненты, которые просто рисуют на экране квадрат и изменяют его цвет. Чтобы было более наглядно, добавим вызов функции sleep()
, который добавит задержку в componentDidMount()
и в функцию, которую мы передаем в useEffect()
.
import React from "react"; const sleep = (duration) => { const start = new Date().getTime(); let end = start; while(end < start + duration) { end = new Date().getTime(); } } class Component extends React.Component { state = { color: "green" }; componentDidMount() { sleep(3000); if (this.state.color === "green") { this.setState({color: "red"}); } } render() { console.log('Рендер классового компонента, цвет', this.state.color); return ( <div style={{backgroundColor: this.state.color, width: "100px", height: "100px"}}></div> ); } } export default Component;
Рендер классового компонента, цвет green Рендер классового компонента, цвет red
При запуске приложения мы увидим, как браузер зависает на три секунды, а потом показывает красный квадрат. Хотя рендеров было два, зеленого квадрата мы не видим, изменения в браузерный DOM были отправлены только один раз — после второго рендера.
import React, {useState, useEffect, useLayoutEffect} from "react"; const sleep = (duration) => { const start = new Date().getTime(); let end = start; while(end < start + duration) { end = new Date().getTime(); } } const Component = () => { const [color, setColor] = useState("green"); useEffect(() => { sleep(3000); if (color === "green") { setColor("red"); } }, []); // только при монтировании console.log('Рендер функционального компонента, цвет', color); return ( <div style={{backgroundColor: color, width: "100px", height: "100px"}}></div> ); }; export default Component;
Рендер функционального компонента, цвет green Рендер функционального компонента, цвет red
При запуске приложения мы увидим, как браузер показывает зеленый квадрат, а через три секунды — красный. Рендеров было два, изменения в браузерный DOM были отправлены дважды — после каждого рендера. Чтобы поведение стало таким же, как и у классового компонента, надо заменить useEffect()
на useLayoutEffect()
.
Хук мемоизации useCallback
Пусть у нас есть компонент Parent
и дочерний компонент Child
, комонент Child
получает от родителя проп name
. У компонента Parent
есть кнопка, которая изменяет его состояние. Клик по кнопке вызывает рендер компонента Parent
— а заодно и компонента Child
.
import {useState} from 'react'; const Parent = (props) => { const [rand, setRand] = useState(0); const handleParentClick = () => { console.log('Изменение состояния Parent'); setRand(Math.random()); }; console.log('Рендер компонента Parent'); return ( <div> <h1>Родитель</h1> <Child name="Ребенок" /> <button onClick={handleParentClick}>Изменить состояние Parent</button> </div> ); };
const Child = (props) => { console.log('Рендер компонента Child'); return <h2>{props.name}</h2> };
После клика по кнопке мы увидим в консоли следующие сообщения:
Изменение состояния Parent Рендер компонента Parent Рендер компонента Child
Поскольку мы передаем компоненту Child
всегда одно и тот же значение пропа name
, нет необходимости в его повторном рендере. Этого можно добиться, используя React.memo()
:
import React from 'react'; const Child = React.memo((props) => { console.log('Рендер компонента Child'); return <h2>{props.name}</h2> });
После клика по кнопке мы увидим в консоли следующие сообщения:
Изменение состояния Parent Рендер компонента Parent
Мы обернули компонент в вызов React.memo()
, чтобы мемоизировать результат и избежать повторного рендера при тех же пропсах.
React.memo()
затрагивает только изменения пропсов. Если функциональный компонент обёрнут в React.memo()
и использует useState
, useReducer
или useContext
, он будет повторно рендериться при изменении состояния или контекста.
Это прекрасно работает, пока мы не передадим компоненту Child
через пропсы callback-функцию:
import {useState} from 'react'; const Parent = (props) => { const [rand, setRand] = useState(0); const handleParentClick = () => { console.log('Изменение состояния Parent'); setRand(Math.random()); }; const handleChildClick = () => { console.log('Обработчик клика Child'); }; console.log('Рендер компонента Parent'); return ( <div> <h1>Родитель</h1> <Child name="Ребенок" childClickHandler={handleChildClick} /> <button onClick={handleParentClick}>Изменить состояние Parent</button> </div> ); };
import React from 'react'; const Child = React.memo((props) => { console.log('Рендер компонента Child'); return <h2 onClick={props.childClickHandler}>{props.name}</h2> });
После клика по кнопке мы увидим в консоли следующие сообщения:
Изменение состояния Parent Рендер компонента Parent Рендер компонента Child
Что происходит в этом случае? Рендер компонента Parent
— это вызов функции Parent
. Каждый новый вызов функции выполняет все тело функции, создавая новую ссылку handleParentClick
, которую мы передаем Child
как childClickHandler
. А React.memo()
сравнивает старую и новую ссылки на функцию. И видит, что они разные — значит, нужен новые рендер компонента Child
.
const one = () => {}; const two = () => {}; console.log(one === two); // false
Ну вот, мы и добрались до самого главного. Если обернуть callback-функцию вызовом useCallback()
— то useCallback()
всегда будет возвращать ссылку на одну и ту же функцию (пока не изменятся зависимости, это второй аргумент хука, подробности здесь).
import React, {useState, useCallback} from 'react'; import Child from './Child'; const Parent = (props) => { const [rand, setRand] = useState(0); const handleParentClick = () => { console.log('Изменение состояния Parent'); setRand(Math.random()); }; const handleChildClick = useCallback(() => { console.log('Обработчик клика Child') }, []); console.log('Рендер компонента Parent'); return ( <div> <h1>Родитель</h1> <Child name="Ребенок" childClickHandler={handleChildClick} /> <button onClick={handleParentClick}>Изменить состояние Parent</button> </div> ); };
После клика по кнопке мы увидим в консоли следующие сообщения:
Изменение состояния Parent Рендер компонента Parent
Хук мемоизации useMemo
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
Хук useMemo()
возвращает мемоизированное значение. Принимает в качестве аргуменов «создающую» функцию и массив зависимостей. Позволяет улучшить производительность, когда при каждом ренедере нужно выполнять повторяющиеся сложные вычисления. Если массив зависимостей не был передан, новое значение будет вычисляться при каждом рендере.
useCallback(fn, deps)
— это эквивалент вызова useMemo(() => fn, deps)
.
Второе назначение хука — использование его по аналогии с хуком useCallback
, но для массивов и объектов. Массивы и объекты имеют ссылочный тип, как и функции — и если их передавать дочернему компоненту как пропсы — будут лишние рендеры.
Без использования хука useMemo
:
const Parent = (props) => { const [rand, setRand] = useState(0); const changeState = () => { console.log('Изменение состояния Parent'); setRand(Math.random()); }; const obj = {one: 1, two: 2}; const arr = ['one', 'two']; console.log('Рендер компонента Parent'); return ( <div> <h1>Родитель</h1> <Child name="Ребенок" obj={obj} arr={arr} /> <button onClick={changeState}>Изменить состояние Parent</button> </div> ); };
const Child = React.memo((props) => { console.log('Рендер компонента Child'); return <h2>{props.name}</h2> });
Изменение состояния Parent Рендер компонента Parent Рендер компонента Child
Теперь будем использовать хук:
const Parent = (props) => { const [rand, setRand] = useState(0); const changeState = () => { console.log('Изменение состояния Parent'); setRand(Math.random()); }; const obj = useMemo(() => ({one: 1, two: 2}), []); const arr = useMemo(() => ['one', 'two'], []); console.log('Рендер компонента Parent'); return ( <div> <h1>Родитель</h1> <Child name="Ребенок" obj={obj} arr={arr} /> <button onClick={changeState}>Изменить состояние Parent</button> </div> ); };
Изменение состояния Parent Рендер компонента Parent
Хук useImperativeHandle
Надо сразу сказать, что без этого хука можно обойтись. Того, что мы уже знаем о хуках, достаточно, чтобы создать тот функционал, который предоставляет useImperativeHandle()
. Вообще не очень понятно, зачем он нужен — но разработчики React иногда так делают. Например, это можно видеть на примере useCallback()
и useMemo()
.
В обычном потоке данных React родительские компоненты могут взаимодействовать с дочерними только через пропсы. Чтобы модифицировать потомка, мы должны заново отрендерить его с новыми пропсами. Тем не менее, могут возникать ситуации, когда требуется императивно изменить дочерний элемент, обойдя обычный поток данных (см. здесь).
Рассмотрим простой пример получения ссылки на DOM-элемент, когда мы в компоненте Form
создаем объект mailInputRefer
со свойством current
, передаем его через проп reference
дочернему компоненту Input
, а дочерний компонент передает его элементу <input>
через специальный атрибут ref
. В итоге компонент Form
получит ссылку на элемент <input>
и сможет установить фокус, задать стиль и т.п.
import {useRef, useState} from 'react'; const Form = () => { const [mail, setMail] = useState(''); const mailInputRefer = useRef(); const handleChange = (event) => { setMail(event.target.value); } const handleSubmit = (event) => { /* * проверяем, что поле mail заполнено */ if (mail.trim() === "") { event.preventDefault(); // отменяем отправку формы mailInputRefer.current.style.backgroundColor = "#fdd"; // красный фон mailInputRefer.current.focus(); // устанавливаем фокус для ввода mail } else { mailInputRefer.current.style.backgroundColor = ""; } } return ( <form onSubmit={handleSubmit}> <Input name="mail" value={mail} changeHandler={handleChange} reference={mailInputRefer} placeholder="Адрес почты" /> <input type="submit" value="Отправить" /> </form> ); };
const Input = (props) => ( <input type="text" name={props.name} value={props.value} onChange={props.changeHandler} ref={props.reference} placeholder={props.placeholder} /> );
Так что же предлагает хук useImperativeHandle
? А предлагает он усложнить этот простой и понятный код, чтобы разработчику жизнь мёдом не казалась.
import {useRef, useState} from 'react'; const Form = () => { const [mail, setMail] = useState(''); const mailInputRefer = useRef(); const handleChange = (event) => { setMail(event.target.value); } const handleSubmit = (event) => { /* * проверяем, что поле mail заполнено */ if (mail.trim() === "") { event.preventDefault(); // отменяем отправку формы mailInputRefer.current.style.backgroundColor = "#fdd"; // красный фон mailInputRefer.current.focus(); // устанавливаем фокус для ввода mail } else { mailInputRefer.current.style.backgroundColor = ""; } } return ( <form onSubmit={handleSubmit}> <Input name="mail" value={mail} changeHandler={handleChange} ref={mailInputRefer} placeholder="Адрес почты" /> <input type="submit" value="Отправить" /> </form> ); };
import {useRef, forwardRef, useImperativeHandle} from 'react'; const Input = forwardRef((props, refer) => { const textInputRefer = useRef(); useImperativeHandle(refer, () => ({ style: textInputRefer.current.style, focus: () => textInputRefer.current.focus() })); return ( <input type="text" name={props.name} value={props.value} onChange={props.changeHandler} ref={textInputRefer} placeholder={props.placeholder} /> ); });
В компоненте Input
мы получаем ссылку на элемент <input>
, а потом в useImperativeHandle
определяем, что должно быть в свойстве current
объекта mailInputRefer
, который мы создали в компоненте Form
. Поскольку нам нужно изменять стиль и устанавливать фокус, именно эти свойства HTMLInputElement
туда и записываем.
{ current: { style: CSSStyleDeclaration {...}, focus: () => textInputRefer.current.focus() } }
Но в принципе, нам было бы достаточно записать туда только свойство input
— которое было бы ссылкой на <input>
элемент. И через это свойство получить style
, focus
— да все, что потребуется. Пожалуй, это будет более универсальное решение — мало ли, что может потребоваться.
useImperativeHandle(refer, () => ({ input: textInputRefer }));
{ current: { input: ссылка_на_input_элемент } }
Но в этом случае нам вообще не нужен хук useImperativeHandle
. Можно просто передать refer
через специальный атрибут ref
. Потому как хук нужен, чтобы записать в mailInputRefer
только часть свойств textInputRefer
. А если мы хотим сохранить в mailInputRefer
DOM-элемент <input>
целиком — все упрощается.
import {forwardRef} from 'react'; const Input = forwardRef((props, refer) => { return ( <input type="text" name={props.name} value={props.value} onChange={props.changeHandler} ref={refer} placeholder={props.placeholder} /> ); });
Хук useImperativeHandle
используется в паре с forwardRef
и никак иначе. Ссылку на объект mailInputRefer
мы передаем из Form
в Input
через специальный атрибут ref
. В компоненте Input
мы создаем переменную textInputRefer
, которую надо передать в input
через специальный атрибут ref
. У компонента Input
, обернутого в forwardRef
, будет второй аргумент refer
. И этот refer
нужно передать useImperativeHandle
первым параметром. Главное — ничего не перепутать и ничего не забыть.
Хук состояния useReducer
Хук состояния useReducer
очень похож на useState
, но дает больший контроль над управлением состояния. Он принимает функцию редюсер и начальное состояние в качестве аргументов, а возвращает состояние и метод dispatch
.
const [state, dispatch] = React.useReducer(reducerFn, initialState, initFn);
Редюсер — это шаблон, взятый из Redux. Представляет собой функцию, которая принимает в качестве аргументов предыдущее состояние и требуемое действие, а возвращает — следующее состояние.
const reducer = (prevState, action) => { /* .......... */ return newState; }
Действие представляет собой строку, которая описывает — что произойдет. И основываясь на этой информации, редюсер определяет — как должно измениться состояние. Действия передаются через функцию dispatch(action)
.
Лучше использовать useReducer
вместо useState
, когда следующее состояние зависит от предыдущего. Это даст предсказуемость в изменении состояния — вся логика сосредоточена в одной функции и эта функция не зависит от React.
const reducer = (state, action) => { switch (action) { case 'INCREMENT': return state + 1; case 'DECREMENT': return state - 1; default: return state; } } function Counter() { const [state, dispatch] = React.useReducer(reducer, 0); return ( <div> <button onClick={() => dispatch('DECREMENT')}>minus</button> <strong>{state}</strong> <button onClick={() => dispatch('INCREMENT')}>plus</button> </div> ); }
Редюсер — «чистая» функция, это значит, что у нее нет побочных эффектов. Она возвращает одно и то же значение, если задать одни и те же аргументы. Такую функцию намного проще тестировать — нет побочных эффектов и она не зависит от React.
Еще один пример использования хука — компонент Color
позволяет задать цвет заголовка с помощью шести кнопок, каждая из которых увеличивает или уменьшает значение одной из RGB-составляющей цвета.
import React from 'react'; const limitRGB = (number) => (number < 0 ? 0 : number > 255 ? 255 : number); const step = 50; const reducer = (state, action) => { // обычно action — объект с ключами type и payload; type — действие, которое нужно // выполнить, а payload — дополнительные данные, которые необходимы для вычисления switch (action.type) { case 'INCREMENT_RED': return { ...state, red: limitRGB(state.red + step) } case 'DECREMENT_RED': return { ...state, red: limitRGB(state.red - step) } case 'INCREMENT_GREEN': return { ...state, green: limitRGB(state.green + step) } case 'DECREMENT_GREEN': return { ...state, green: limitRGB(state.green - step) } case 'INCREMENT_BLUE': return { ...state, blue: limitRGB(state.blue + step) } case 'DECREMENT_BLUE': return { ...state, blue: limitRGB(state.blue - step) } default: return state; } } export default function Color() { const [state, dispatch] = React.useReducer(reducer, {red: 0, green: 0, blue: 0}); return ( <React.Fragment> <h1 style={{color: `rgb(${state.red}, ${state.green}, ${state.blue})`}}> Выбор цвета </h1> <p> Красный: {state.red} <button onClick={() => dispatch({type: 'INCREMENT_RED'})}>плюс</button> <button onClick={() => dispatch({type: 'DECREMENT_RED'})}>минус</button> </p> <p> Зеленый: {state.green} <button onClick={() => dispatch({type: 'INCREMENT_GREEN'})}>плюс</button> <button onClick={() => dispatch({type: 'DECREMENT_GREEN'})}>минус</button> </p> <p> Синий: {state.blue} <button onClick={() => dispatch({type: 'INCREMENT_BLUE'})}>плюс</button> <button onClick={() => dispatch({type: 'DECREMENT_BLUE'})}>минус</button> </p> </React.Fragment> ); }
Поиск: Hook • JavaScript • React.js • Web-разработка • Frontend • Теория • Функция • Хук • useContext • useCallback • useMemo