Pull to refresh

Чего мне не хватало в функциональных компонентах React.js

Reading time5 min
Views5.4K

За последние годы о 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;
}

Код на Github

Tags:
Hubs:
Total votes 8: ↑7 and ↓1+6
Comments22

Articles