Pull to refresh

Comments 34

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

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

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

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

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

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

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

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

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

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

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

вместо
const ComponentWithRouterProps = useCurry(Component, routerProps);

делать
const routerProps = useRouter();

return <Component {...routerProps} />

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

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

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

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

const [, forceUpdate] = useState();

forceUpdate({});

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

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

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

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 стал необязательным, но всё-таки возможным. Что-то навскидку непонятно, как это сделать на генериках (хотя я не шибко ведаю в типизации).

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

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


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

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

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

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


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

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

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

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

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

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

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

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

const request = useDialog(SomeDialogComponent);

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

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

про 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 на любой чих).

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

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

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


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

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

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


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

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

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

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

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

Вот не понимаю людей, которые с модалками работают через хуки или через 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 и она рендерит заново контент (но без анимации появления).

Идея-то неплохая, если SPA без SSR, но есть ряд сайд-эффектов.

Во-первых, невозможно отловить из другого компонента, что модалка открыта и закрыть ее - нельзя вызвать context.actions.hideModal. Соответственно, не выполнить какую-то логику при открытой модалке - например, я в форме внутри модалки могу вызвать context.actions.shakeModal при ошибке валидации, а тут непонятно - открыта она или нет, и не открыт ли второй инстанс.

Во-вторых, как вы это сериализуете если есть SSR? При хранении в глобальном сторе `modals: [{ message, type }]` это легко сериализуется, а если хранилище открытости локальное - все намного сложнее.

В-третьих, приходится каждый раз оперировать не просто параметром в глобальном сторе, у которого нет зависимости от Реакта, а импортить реактовый компонент. А если он импортит глобальный стор то получится циклическая зависимость.

В-четвертых, afterHideCallback удобнее определять при открытии, а не при закрытии - если например используется в целях онбординга (симуляция пользовательских действий). Да и нужных данных при вызове закрытия может не быть.

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

Модалки бывают разные. Иногда это некие уведомления или вопросы к пользователю (аналогичные браузерным alert/confirm/prompt), которые возникли по ходу выполнения функции, возможно при определенных условиях. Чтобы показывать их через глобальный стор, придется разрезать логику выполнения на "до и после модалки", это может быть неудобно. В то же время рисовать такие диалоги в SSR тоже нет смысла.

форме внутри модалки могу вызвать context.actions.shakeModal при ошибке валидации, а тут непонятно - открыта она или нет, и не открыт ли второй инстанс.

Эта форма вообще не должна знать про какие-то внешние обстоятельства. Вместо этого лучше принимать проп "onValidationError", который вызывать при ошибке. А модалка передаст в тот проп уже функцию встряски себя.

Поинт про циклическую зависимость не совсем понятен.

Поигрался с кодом. Не совсем нравится то, что обновление компонента идет из 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 намеренно, чтобы возвращался новый компонент, если исходный поменяется.

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

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

Ещё раз глянул, и тут тоже есть баги. Этот простой вариант работает, только когда компонент обновляется "снаружи", то есть если обновился парент. Если каррированный компонент передать в memo-парент и там отрендерить, он не сможет обновляться при изменении пропсов, с которыми он был каррируется. Хитрая штука этот Реакт, всегда можно накосячить )

https://codesandbox.io/s/distracted-water-9ml1j?file=/src/App.tsx

Там 3 варианта:
useCurriedComponentOrigin - ваш итоговый, он работает с багами, если карированный компонент отрендерить более одного раза, потому что useEffect в хуке обновляет только один экземпляр.
useCurriedComponentV1 - вот этот мой, он не обновляет внутри memo.
useCurriedComponentV2 - обновляет все, но внутри мемо только через useEffect, как бы "вдогонку". Увы, без useEffect никак..

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

Да, бывает ) Иногда сложно протипизировать.

Зато как только опишешь типы, сразу кодится как по рельсам.

renderRef.current = render;

Не советую так делать вне effect-ов. С точки зрения React render должен быть pure, без side-effect-ов. А вы здесь мутируете reference. Я ловил примерно такие приколы:


  • при открытых React DevTools происходит fake-render компонента (в попытке спарсить имена сабхуков)
  • во время него вместо useCallback и других хуков передаются бесполезные обёртки
  • useCallback возвращал метод пустышку
  • такой же код ref.current = ... сохранял в себе ту пустышку
  • новых render-ов (уже нормальных) не происходило
  • работа приложения ломалась

Беглый поиск привёл к issue на github, где чёрным по белому говорилось, что это косяк программиста, а не react-dev-tools, ибо side-effect-ы во время рендера допускаются только при 1-ом рендере для инициализации. Позднее они правда убрали такое поведение тулзов по-умолчанию (но оставили по кнопке).


Как правильно? делать это присваивание в useLayoutEffect-е.


, [ComponentFunction, renderRef]

renderRef — static value, не нужно его указывать в deps


upd: А ну и у вас всё поломается, если это делать в useLayoutEffect-е. Потому что render нужен до useLayoutEffect-а.

renderRef — static value, не нужно его указывать в deps

Я опасался, что react-hooks/exhaustive-deps придолбается, но он видимо умеет понять что к чему.

А ну и у вас всё поломается, если это делать в useLayoutEffect-е. Потому что render нужен до useLayoutEffect-а.

Да, здесь принципиально не получается сuseLayoutEffect

во время него вместо useCallback и других хуков передаются бесполезные обёртки

Это можно как-то поймать в коде? Чтобы не присваивать в ref

Ну то есть объект в рефе один и тот же, только мутируется. Будут всплывать вышеописанные баги с девтулзами?

Попробуйте подменить все хуки на вот эту бурду. Сейчас ещё ничего, оно хотя бы тот же самый callback возвращает. А в то время когда я это дебажил, там было что-то вроде:


useCallback = () => () => null;

Но почему-то не могу найти того кода в их репе. Даже за 18-й год уже по-другому. Странно.


В общем и целом лучше просто сделать бан на мутацию ref-ов в приложении во время рендера, чем гадать где оно сломается, и когда. ИМХО. Кажется в React рендер-функции рассматриваются просто как генератор JSX-звеньев и ничего более. И React волен эту render-функцию насиловать любым ему удобным способом.


Ссылки: [1], [2]

Будут всплывать вышеописанные баги с девтулзами?

Тот баг с которым столкнулся я связан не с объектом. Он связан с тем что мы сохраняем неправильное состояние в ref-е. А на каком уровне вложенности того объекта мы это делаем уже не важно. Важно что после render-а там будет лежать мусор. И если после рендера им воспользоваться… В моём случае я дебагом дошёл до того, что вместо нормального event-handler-а запускалась пустая функция заглушка. Видели бы вы мои глаза в этот момент :D


Все варианты того, что может пойти не так сложно предугадать. Т.к. side-effect-ы и их сочетания бывают разными.

Sign up to leave a comment.

Articles