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

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

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

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

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

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

Мы будем называть каррированием преобразование функции 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 (
    <div>
    	<p>{title}</p>
    	<p>{text}</p>
    </div>
  )
}  

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

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

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

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

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

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

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

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

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

Реализация useCurry

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

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

function useCurry(ComponentToCurry, props) {
    const CurriedComponent = function(restProps) {
      	return <ComponentToCurry {...props} {...restProps} />
    };
  
  	return CurriedComponent;
}  

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

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

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

useCallback:

function useCurry(ComponentToCurry, props) {
    const CurriedComponent = useCallback((restProps) => {
      	return <ComponentToCurry {... props} {... restProps} />
    }, [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 {...propsRef.current} 
          					{...restProps} />;
        }, [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 {...this.props} 
              					{...curriedPropsRef.current} />;
            }
        },
    		[ComponentToCurry]
		);

    return CurriedComponent;
}

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

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

Практическое применение

Рассмотрим модальные окна.
Часто можно увидеть следующий код:

const [(d/D)ialog, setOpen, (open)] = useDialog(...),

где (d/D)ialog либо элемент, либо компонент.

В первом случае нам надо внутрь useDialog прокидывать все пропсы диалога (например, текст) помимо open, а внутри useDialog прокидывать их в компонент Dialog, что вряд ли кому-то покажется элегантным решением.
Выглядеть это будет так:

function useDialog(dialogProps) {
  const [open, setOpen] = useState(false);
  const dialog = useMemo(() => <Dialog open={open} {...dialogProps} />, 
                         [dialogProps, open]);
  return [dialog, setOpen];
}

function DialogWrapper() {
  const [dialog, setOpen] = useDialog({text: 'Это диалог!'});
  useEffect(() => {
      setTimeout(() => setOpen(true), 3000);
  }, []);
  return dialog;
}  

Во втором случае нам надо прокидывать open внутрь Dialog, хотя это мог бы сделать useDialog:

function useDialog() {   
  const [open, setOpen] = useState(false);   
  return [Dialog, setOpen, open];
}  

function DialogWrapper() {   
  const [Dialog, setOpen, open] = useDialog();   
  useEffect(() => {       
    setTimeout(() => setOpen(true), 3000);   
  }, []);   
  return <Dialog text='Это диалог' open={open} />;
}  

С useCurry это выглядело бы так:

function useDialog() {   
  const [open, setOpen] = useState(false); 
  const CurriedDialog = useCurry(Dialog, {open});
  return [CurriedDialog, setOpen];
}  

function DialogWrapper() {   
  const [Dialog, setOpen] = useDialog();   
  useEffect(() => {       
    setTimeout(() => setOpen(true), 3000);   
  }, []);   
  return <Dialog text='Это диалог'/>;
}  

На мой взгляд, последний вариант превосходит первые два по простоте и читаемости.
Более того, в отличие от первого варианта мы получили возможность открывать несколько диалогов одновременно, достаточно будет написать
<Dialog text='Первый диалог' />
<Dialog text='Второй диалог' />

Примечание: выше предполагается, что useDialog устроен значительно сложнее, а setOpen не просто меняет open, но также производит сложные действия. В примере же можно вообще обойтись без useDialog, но это сделано для наглядности

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

Комментарии (26)


  1. lexey111
    05.12.2021 13:22
    +6

    За статью спасибо, но... чтобы - что? Какая задача решается?


    1. bevertax Автор
      05.12.2021 16:07

      Изначально я решал следующую проблему: у меня был готовый компонент A с большим количеством пропсов, при этом частью этих пропсов props_1 я хотел управлять внутри отдельного хука hook, а часть пропсов props_2 прокидывать в него из другого компонента B. Я также хотел, чтобы компонент B ничего не знал о пропсах props_1.

      Вот и выходит, что мой hook должен был принять компонент A, вставить в него свои пропсы props_1, и вернуть наружу в компонент B более простой каррированный компонент.

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


      1. lexey111
        05.12.2021 16:24

        1. bevertax Автор
          05.12.2021 16:43

          HOC не позволит менять каррированные пропсы. То есть один раз их вставил в компонент и дальше пользуешься тем что получилось. Но в моём случае hook должен был многократно менять каррированные пропсы


          1. DmitryKazakov8
            05.12.2021 18:30

            Почему не позволит? В HOC можно сделать такие же propTypes, как в обернутом компоненте, просто если они не переданы использовать дефолтные значения return <OriginalComponent prop1={props.prop1 || 'default value'} />


  1. kreddos
    05.12.2021 13:40
    +2

    render() {
        return <ComponentToCurry {...this.props} 
            {...curriedPropsRef.current} />;
    }

    Тут я бы поменял местами this.props и curriedProps, на мой вгляд у пропсов компонента должен быть приоритет выше.

    А так, поддерживаю коммент выше, не очень понимаю какую поблему можно этим решить


  1. kahi4
    05.12.2021 13:41

    useMemo не подойдёт в данном случае, потому что в будущем он может сбросить состояние в любой момент и пересоздать объект, см документацию

    Но, как заметили выше, не очень понятно зачем это нужно. Какая-то хитрая альтернатива HOC? Как по мне, так это только затрудняет чтение кода, потому что нужно будет просматривать больше кода чтобы понять откуда появился пропс, когда можно было просто сделать что-то типа:

    вместо
    const ComponentWithRouterProps = useCurry(Component, routerProps);
    
    делать
    const routerProps = useRouter();
    
    return <Component {...routerProps} />

    Или, раз уж компоненту нужен роутер, просто делать useRouter внутри, при необходимости делать разделение через контексты. Это если про переиспользование кода.

    Если ответ: для упрощения тестирования, то ИМХО HOC контейнеры гораздо проще в написании, проще в тестировании, и проще в переиспользовании.


  1. EdgarAbgaryan
    05.12.2021 15:13
    +4

    спасибо, пиши ещё

    только классовый компонент не нужен был. для форс-апдейта можно и useState юзать

    const [, forceUpdate] = useState();

    forceUpdate({});

    а конкретней вот так: https://codesandbox.io/s/mutable-wave-sfxhz?file=/src/App.js

    практической пользы правда не смог придумать для хука. всегда заместо него можно просто заспредить пропсы <ComponentToCurry {...props1} {...props2} />


    1. bevertax Автор
      05.12.2021 16:14

      Спасибо за useState! Сколько ни пытался избавиться от классового компонента, так и не смог придумать альтернативы


  1. Alexandroppolus
    05.12.2021 16:28

    const CurriedEC = useCurry(ExampleComponent, {text: '...'}});

    При таком использовании props в useCurry всегда новый, даже если "начинка" не поменялась. Бесполезно ставить его в зависимости хуков с зависимостями. Надо снаружи обертывать в useMemo, либо внутри useCurry поюзать какой-нибудь useShallowEqual, чтобы это дело исправить.

    Присоединяюсь к непонимающим великий смысл этого хука.

    Да и с типами TS тут придется повоевать. Скажем, для ExampleComponent: FC<{title: string, text: string}> из примера результат useCurry(ExampleComponent, {text: '...'}}) должен получить тип FC<{title: string, text?: string}> , чтобы text стал необязательным, но всё-таки возможным. Что-то навскидку непонятно, как это сделать на генериках (хотя я не шибко ведаю в типизации).


    1. bevertax Автор
      05.12.2021 18:05

      Да, с типами немного сложно.
      Вот небольшой пример: https://codesandbox.io/s/eloquent-marco-hqfg1


    1. indestructable
      06.12.2021 00:30

      Curried<TCurriedProps, TOrigProps> = TCurriedProps & Optional<Omit<TOrigProps, keyof TCurriedProps>> как-то так


  1. faiwer
    06.12.2021 01:34
    +1

    Какой-то лютый оверинжиниринг получился, не понятно зачем это вообще всё нужно. Особенно первые версии, которые убивали уже отрендеренные компоненты на каждом рендере родителя.


        useEffect(() => {
            propsRef.current = props;
            curriedComponentRef.current?.forceUpdate();
        }, [props]);

    Вот это , [props] бесполезно. У вас всегда props новые ({open}). Так что вы при любом рендере родительского компонента вызываете и render дочернего. Просто очень странным способом :)


    1. faiwer
      06.12.2021 01:38

      function useDialog(dialogProps) {
        const [open, setOpen] = useState(false);
        const dialog = useMemo(() => <Dialog open={open} {...dialogProps} />, 
                               [dialogProps, open]);
        return [dialog, setOpen];
      }

      Тут та же история. Тут вы убиваете весь диалог на каждом рендере (потому что dialogProps всегда новые).


      P.S. не понял почему вы с диалогом работаете настолько странно. Это прямо ну очень нестандартно и неудобно.


      1. bevertax Автор
        06.12.2021 02:11

        Выше уже написали про useMemo, который, конечно, неплохо было бы использовать. Я не хотел написать идеальный код, скорее показать общую идею, которую при желании можно улучшать.

        Кажется, вы не правы насчёт того, что я "убиваю" диалог. Он будет каждый раз рендериться, но не монтироваться. Но ведь у реакта по умолчанию именно такое поведение и есть: родитель рендерится -> рендерится потомок.

        А как обычно работают с диалогом? Просто я встречал именно те две версии, о которых написал. Обычно есть компонент, который принимает в себя разные пропсы, включая open, и есть отдельно хук, который этим компонентом управляет и наружу выбрасывает setOpen


        1. Alexandroppolus
          06.12.2021 10:21

          Кажется, вы не правы насчёт того, что я "убиваю" диалог. Он будет каждый раз рендериться, но не монтироваться.

          При изменении стейта open имеем каждый раз новый компонент CurriedDialog, то есть перемонтирование.

          А как обычно работают с диалогом?

          Обычно просто рендерится исходный диалог, с передачей всех пропсов. А так, возможны варианты. Мне показался удобным для диалогов (именно диалогов, где надо что-то спросить у юзера) такой способ:

          const request = useDialog(SomeDialogComponent);
          
          ...
          const userResponse = await request(params);

          То есть не рисую модалку самостоятельно, а передаю в специальный хук, который возвращает функцию вызова, с промисом для ответа пользователя. В некоторых случаях, например, когда попапчик надо вызвать при некотором условии, сильно помогает.


        1. faiwer
          06.12.2021 11:20
          +1

          про useMemo, который, конечно, неплохо было бы использовать

          Ага. Очень удобно:


          useCarry(Component, useMemo(someProps, [someProps.p1, someProps.P2, ...]);

          Он будет каждый раз рендериться, но не монтироваться

          А вы проверьте. Это уже вопрос не render-а, а реконсиляции. Там React находит вместо старого компонента новый. Ввиду чего убивает его и создаёт новый. Отдельно отмечу, что он помимо этого убивает и связанный с ним DOM.


          А как обычно работают с диалогом?

          Кто как. Мне больше всего нравится версия: HoC сверху, hook снизу. Это в типовом случае когда в приложении нельзя открыть два таких диалога одновременно. Хук снизу при этом использует родительский контекст и возвращает что-то типа: { open, close, isOpened }, где open это метод возвращающий Promise который resolve-ится тогда когда диалог закрыт. Такой подход удобен когда диалог нужен сразу во многих местах.


          Но на самом деле вариантов уйма. Такого странного как в статье я не видел никогда. Вот ещё простая версия:


          const Something = () => {
            const visibility = useVisibility(false /* hide */); 
            // ^ { open, close, isOpen };
          
            return <Dialog {...visibility} text={...}/>;
          }

          Непонятно зачем вам вообще понадобилось каррирование, да ещё и на уровне компонент (где есть unmount на любой чих).


          1. bevertax Автор
            06.12.2021 22:05
            +1

            Это уже вопрос не render-а, а реконсиляции. Там React находит вместо старого компонента новый. Ввиду чего убивает его и создаёт новый. Отдельно отмечу, что он помимо этого убивает и связанный с ним DOM.

            Всё-таки вы не правы, это вопрос рендера. Компонент всегда имеет одинаковый тип Dialog, поэтому размонтирование не происходит. <Dialog /> каждый раз новый, Dialog один и тот же, так и должно быть


            1. faiwer
              06.12.2021 23:05

              Пригляделся внимательнее, да. Я напутал. У вас ведь там ещё такой был:


                  const CurriedComponent = useCallback((restProps) => {
                      return <ComponentToCurry {... props} {... restProps} />
                  }, [props, ComponentToCurry]);

              Sorry :) Два примера смешались. Поторопился. В варианте с createElement этой проблемы не будет, всё так.


            1. faiwer
              06.12.2021 23:08
              +1

              Кстати говоря, сильно большого смысла вот тут мемоизировать что-то нет:


              const dialog = useMemo(
                () => <Dialog open={open} {...dialogProps} />, 
                [dialogProps, open]
              );

              всё равно на выходе элемент. Помимо того что memo всегда будет бесполезным (из-за dialogProps), можно просто вернуть этот элемент как есть и доверить его React-у


  1. Vlad_Murashchenko
    06.12.2021 13:02

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

    Также это скорее частичное применение, чем каррирование. Я бы переименовал хук в usePartial.


  1. mobilz
    06.12.2021 22:15

    Интересно, не бросай это дело (в плане статей на хабре), НО ) зачем это надо, когда есть mobx?


  1. pistonsky
    07.12.2021 01:34

    Вот не понимаю людей, которые с модалками работают через хуки или через redux store их показывают. У меня на всё приложение одна модалка, которая написана таким образом, чтобы её можно было удобно вызывать вот так:

    import Modal from 'ui/modal';

    Modal.show(content, options);
    Modal.hide(afterHideCallback, options);


    Мега удобно. Рекомендую. Таких модалок замонтировано две, чтобы можно было (если вдруг очень надо) показать одну поверх другой. content - это React.ReactNode, а в опциях можно передать, к примеру, title, какой-нибудь subtitle, или что-нибудь ещё, если не хочется писать свой content. Также там и top можно пробросить, чтобы модалка показалась поверх другой. Я ещё придумал minShowTime и autoHide - чтобы модалка сама собой закрылась (либо по вызову Modal.hide), но минимум через определённое время (бывает полезно для состояний загрузок, которые можно делать как модалки на весь экран, и даже блокировать тем самым жест "назад" на айфоне если не хочется чтобы юзер "сбежал" пока какой-то важный процесс происходит). А, ну и если надо что-то внутри модалки обновить из того места, где она была вызвана - то просто вызываешь ещё раз Modal.show и она рендерит заново контент (но без анимации появления).


  1. Alexandroppolus
    07.12.2021 17:59
    +2

    Поигрался с кодом. Не совсем нравится то, что обновление компонента идет из useEffect через forceUpdate (или его хуковый аналог useState) - что-то в этом есть подозрительное. Компонент как будто обновляется отдельно.

    Запилил такую версию:

    кодъ
    type CurriedProps<
        AllProps,
        CurriedPropsNames extends Partial<keyof AllProps>
        > = Omit<AllProps, CurriedPropsNames> &
        { [propName in CurriedPropsNames]?: AllProps[propName] };
    
    export function useCurriedComponent<
        AllProps,
        CurriedPropsNames extends Partial<keyof AllProps>
        >(
        ComponentFunction: React.FC<AllProps>,
        curriedProps: { [propName in CurriedPropsNames]: AllProps[propName] }
    ): React.FC<CurriedProps<AllProps, CurriedPropsNames>> {
    
        const render = (
            Component: React.FC<AllProps>,
            props: CurriedProps<AllProps, CurriedPropsNames>
        ): React.ReactElement => {
            const allProps = {...curriedProps, ...props} as AllProps;
            return <Component {...allProps} />;
        };
    
        const renderRef = useRef(render);
        renderRef.current = render;
    
        return useCallback(
            (props) => renderRef.current(ComponentFunction, props),
            [ComponentFunction, renderRef]
        );
    }

    Здесь каррирование (или частичное применение) компонента сводится к каррированию функции. Да и рендер нормально работает в рамках обновления всего дерева. Плюс, не нужно useMemo для второго параметра в useCurriedComponent, мы тут не зависим от ссылки на объект curriedProps. ComponentFunction добавлен в депсы useCallback намеренно, чтобы возвращался новый компонент, если исходный поменяется.

    Прикольная тема. Реквестую ещё странных хуков.


    1. bevertax Автор
      08.12.2021 03:40

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


    1. mobilz
      08.12.2021 03:54

      вы правы в своих доводах, всё круто. но великий Тор, как же ужасен ts ((