За последние годы о React hooks не писал разве что ленивый. Решился и я.
Помню первое впечатление - WOW-эффект. Можно не писать классы.
Не нужно описывать тип состояния, инициализировать состояния в
конструкторе, теснить всё состояние в одном объекте, помнить о том,
как setState
сливает новое состояние со старым. Не
придется больше насиловать методы componentDidMount
и
componentWillUnmount
запутанной логикой инициализации
и освобождения ресурсов.
Вот простой компонент: управляемое текстовое поле и счетчик, который увеличивается на 1 по таймеру и уменьшается на 10 по нажатию кнопки;
import * as React from "react";interface IState { numValue: number; strValue: string;}export class SomeComponent extends React.PureComponent<{}, IState> { private intervalHandle?: number; constructor() { super({}); this.state = { numValue: 0, strValue: "" }; } render() { const { numValue, strValue } = this.state; return <div> <span>{numValue}</span> <input type="text" onChange={this.onTextChanged} value={strValue} /> <button onClick={this.onBtnClick}>-10</button> </div>; } private onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => this.setState({ strValue: e.target.value }); private onBtnClick = () => this.setState(({ numValue }) => ({ numValue: numValue - 10 })); componentDidMount() { this.intervalHandle = setInterval( () => this.setState(({ numValue }) => ({ numValue: numValue + 1 })), 1000 ); } componentWillUnmount() { clearInterval(this.intervalHandle); }}
превращается в ещё более простой:
import * as React from "react";export function SomeComponent() { const [numValue, setNumValue] = React.useState(0); const [strValue, setStrValue] = React.useState(""); React.useEffect(() => { const intervalHandle = setInterval(() => setNumValue(v => v - 10), 1000); return () => clearInterval(intervalHandle); }, []); const onBtnClick = () => setNumValue(v => v - 10); const onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => setStrValue(e.target.value); return <div> <span>{numValue}</span> <input type="text" onChange={onTextChanged} value={strValue} /> <button onClick={onBtnClick}>-10</button> </div>;}
Функциональный компонент не только в два раза короче, он понятнее: функция умещается в один экран, всё перед глазами, конструкции лаконичны и ясны. Красота.
Но в реальном мире далеко не все компоненты получаются такими
простыми. Давайте добавим нашему компоненту возможность
сигнализировать родителю об изменении числа и строки, а элементы
input
и button
заменим компонентами
Input
и Button
, которые потребуют
обернуть обработчики событий хуком useCallback
.
interface IProps { numChanged?: (sum: number) => void; stringChanged?: (concatRezult: string) => void;}export function SomeComponent(props: IProps) { const { numChanged, stringChanged } = props; const [numValue, setNumValue] = React.useState(0); const [strValue, setStrValue] = React.useState(""); const setNumValueAndCall = React.useCallback((diff: number) => { const newValue = numValue + diff; setNumValue(newValue); if (numChanged) { numChanged(newValue); } }, [numValue, numChanged]); React.useEffect(() => { const intervalHandle = setInterval(() => setNumValueAndCall(1), 1000); return () => clearInterval(intervalHandle); }, [setNumValueAndCall]); const onBtnClick = React.useCallback( () => setNumValueAndCall(- 10), [setNumValueAndCall]); const onTextChanged = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => { setStrValue(e.target.value); if (stringChanged) { stringChanged(e.target.value); } }, [stringChanged]); return <div> <span>{numValue}</span> <Input type="text" onChange={onTextChanged} value={strValue} /> <Button onClick={onBtnClick}>-10</Button> </div>;}
Некрасиво: useCallback
уродует код, приходится
следить за списком зависимостей. Во избежание дублирования я вынес
общий код из обработчика onBtnClick
и
useEffect
в функцию setNumValueAndCall
,
которую также обернул useCallback
, и далее
опирался на её (setNumValueAndCall
) экземпляр как на
зависимость. Возможно, зависимость функции от функции - не лучшее
решение, но поставить в зависимость onBtnClick
и
useEffect
список зависимостей
setNumValueAndCall
тоже наглядностью не
выделяется.
Вдобавок к эстетическим проблемам в новой версии таймер устанавливается заново при каждом нажатии кнопки. Возможно это и не проблема, но вряд ли мы этого хотели.
А классовый компонент переносит тоже расширение функциональности без осложнений.
export class SomeComponent extends React.PureComponent<IProps, IState> { private intervalHandle?: number; constructor() { super({}); this.state = { numValue: 0, strValue: "" }; } render() { const { numValue, strValue } = this.state; return <div> <span>{numValue}</span> <Input type="text" onChange={this.onTextChanged} value={strValue} /> <Button onClick={this.onBtnClick}>-10</Button> </div>; } private onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => { this.setState({ strValue: e.target.value }); const { stringChanged } = this.props; if (stringChanged) { stringChanged(e.target.value); } } private onBtnClick = () => this.setNumValueAndCall(- 10); private setNumValueAndCall(diff: number) { const newValue = this.state.numValue + diff; this.setState({ numValue: newValue }); const { numChanged } = this.props; if (numChanged) { numChanged(newValue); } } componentDidMount() { this.intervalHandle = setInterval( () => this.setNumValueAndCall(1), 1000 ); } componentWillUnmount() { clearInterval(this.intervalHandle); }}
Что же делать? В сложных случаях возвращаться к компонентам-классам? Ну уж нет, нам слишком нравятся возможности, привнесенные хуками.
Предлагаю выносить загромождающие код обработчики в объект класса вместе с зависимостями. Разве так не лучше?
export function SomeComponent(props: IProps) { const [numValue, setNumValue] = React.useState(0); const [strValue, setStrValue] = React.useState(""); const { onTextChanged, onBtnClick, intervalEffect } = useMembers(Members, { props, numValue, setNumValue, setStrValue }); React.useEffect(intervalEffect, []); return <div> <span>{numValue}</span> <Input type="text" onChange={onTextChanged} value={strValue} /> <Button onClick={onBtnClick}>-10</Button> </div>;}type SetState<T> = React.Dispatch<React.SetStateAction<T>>;interface IDeps { props: IProps; numValue: number; setNumValue: SetState<number>; setStrValue: SetState<string>;}class Members extends MembersBase<IDeps> { intervalEffect = () => { const intervalHandle = setInterval(() => this.setNumValueAndCall(1), 1000); return () => clearInterval(intervalHandle); }; onBtnClick = () => this.setNumValueAndCall(- 10); onTextChanged = (e: React.ChangeEvent<HTMLInputElement>) => { const { props: { stringChanged }, setStrValue } = this.deps; setStrValue(e.target.value); if (stringChanged) { stringChanged(e.target.value); } }; private setNumValueAndCall(diff: number) { const { props: { numChanged }, numValue, setNumValue } = this.deps; const newValue = numValue + diff; setNumValue(newValue); if (numChanged) { numChanged(newValue); } };}
Код компонента снова прост и изящен. Обработчики событий вместе с зависимостями мирно ютятся в классе.
Хук useMembers
и базовый класс тривиальны:
export class MembersBase<T> { protected deps: T; setDeps(d: T) { this.deps = d; }}export function useMembers<D, T extends MembersBase<D>>(ctor: (new () => T), deps: (T extends MembersBase<infer D> ? D : never)): T { const ref = useRef<T>(); if (!ref.current) { ref.current = new ctor(); } const rv = ref.current; rv.setDeps(deps); return rv;}