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

67000eefbd0ddd99a17790531b2ffd9b

Добрый день! Я начинающий фулстек-разработчик, и это моя первая статья.

Сегодня я хочу рассказать, как сделать функциональные компоненты в реакте чуть более функциональными, а именно как сделать каррирование функционального компонента.

Предупреждение: в статье использованы как функциональные, так и классовые компоненты

Каррирование

Немного определений.

Вот здесь определение каррирования с википедии.

Мы будем называть каррированием преобразование функции f от N переменных в функцию g от M <= N переменных, которая возвращает функцию w от N — M переменных.

Вот пример:

function sum(first, second, third) {
    return first + second + third;
}

// пусть существует некоторая магическая функция curry, 
// которая принимает на вход функцию и возвращает её каррированную версию
const curriedSum = curry(sum);

// теперь можем делать так:
const sum1 = curriedSum(10)(5)(20);
const sum2 = curriedSum(10, 5)(20);
const sum3 = curriedSum(10)(5, 20);
const sum4 = curriedSum(10, 5, 20);

// и мы ожидаем, что sum1 === sum2 === sum3 === sum4 === 25

А что с реактом?

Пусть имеется следующий функциональный компонент:

function ExampleComponent({title, text}) {
  return (
    

{title}

{text}

) }

И мы хотим к нему применить каррирование. Назовём хук useCurry.

Определим семантику хука:

  1. Хук должен принимать компонент и возвращать каррированный компонент

  2. Хук должен помимо компонента принимать часть пропсов этого компонента и прокидывать их в исходный компонент

  3. При изменении любых пропсов каррированный компонент перерендеривается, как и любой обычный компонент

  4. Каррированный компонент не монтируется каждый раз заново при рендере родительского компонента или изменении пропсов

Запомните свойства 3 и 4, дальше я буду часто к ним обращаться.

Вот пример использования такого хука:

function ParentComponent() {
  const CurriedEC = useCurry(ExampleComponent, 
                             {text: 'Это каррированный компонент'}});
  
  // то же самое, что и 
  return 
}    
  

Реализация useCurry

Наивная реализация:

Достаточно легко придумать реализацию, удовлетворяющую первым трём пунктам

function useCurry(ComponentToCurry, props) {
    const CurriedComponent = function(restProps) {
      	return 
    };
  
  	return CurriedComponent;
}  

Действительно, useCurry будет вызываться и возвращать новый компонент всякий раз при рендере родительского компонента, поэтому новый компонент будет заново монтироваться и, как следствие, рендериться. Это про свойство 3, а первые два и так очевидны.

Для реализации последнего свойства нам надо 'запомнить' ссылку на CurriedComponent, чтобы она не пересоздавалась каждый раз внутри useCurry, поскольку реакт заново монтирует компонент при изменении ссылки на него. Если я не ошибаюсь, именно так работает условный рендеринг, и именно поэтому в списках с одинаковыми компонентами нужны ключи.

Но как запомнить функцию? useCallback должен с этим справиться, да?

useCallback:

function useCurry(ComponentToCurry, props) {
    const CurriedComponent = useCallback((restProps) => {
      	return 
    }, [props, ComponentToCurry]);
  
  	return CurriedComponent;
}

У useCallback есть массив зависимостей, куда очевидным образом поместились props.
Это значит, что при изменении props будет заново пересоздаваться и монтироваться CurriedComponent, а мы хотим его просто перерендерить!
Лучше чем было раньше, но двигаемся дальше.

На помощь приходит useRef. Действительно, если мы не хотим ничего помещать в зависимости useCallback, то только рефы смогут нам в этом помочь. Надо просто положить props в useRef, и менять значение рефа при изменении props, да?

useRef:

function useCurry(ComponentToCurry, props) {
    const propsRef = useRef(props);
    useEffect(() => {
        propsRef.current = props;
    }, [props]);
    
    const CurriedComponent = useCallback(
        (restProps) => {
            return ;
        }, [ComponentToCurry]
    );

    return CurriedComponent;
}

Вот теперь зависимостей кроме ComponentToCurry нет, и мы можем гарантировать, что CurriedComponent монтируется ровно один раз (на совести пользователя хука остаётся ComponentToCurry, ссылка на который, конечно, тоже не должна меняться).

Но это ещё не конец, ведь в погоне за четвёртым свойством мы потеряли третье! useEffect, конечно, обновит реф, но не заставит перерендериться CurriedComponent, ведь propsRef.current никак не взаимодействуетскаррированным компонентом и не входит в число его пропсов

Значит надо силой заставить CurriedComponent рендериться при изменении propsRef.current. А что за сила, спросите вы. Насколько мне известно, только одна конструкция в реакте на такое способна, и, увы, придётся писать классовый компонент.

Я говорю о forceUpdate, эту тёмную магию ещё не перенесли в функциональные компоненты.

forceUpdate:

function useCurry(ComponentToCurry, props) {
    const propsRef = useRef(props);
  	const curriedComponentRef = useRef(undefined);
    useEffect(() => {
        propsRef.current = props;
      	curriedComponentRef.current?.forceUpdate();
    }, [props]);
    
    const CurriedComponent = useMemo(() =>
        class CurriedComponent extends React.Component {
            constructor(restProps) {
                super(restProps);
                curriedComponentRef.current = this;
            }

            render() {
                return ;
            }
        },
    		[ComponentToCurry]
		);

    return CurriedComponent;
}

При изменении propsRef будет вызываться forceUpdate, и CurriedComponent перерендерится.
useCallback исчез, вместо него useMemo

Вот теперь готово!

На этом всё. Если статья найдёт положительный отклик, то я продолжу рассказывать о придуманных мной странных хуках.

© Habrahabr.ru