getDerivedStateFromState – или как сделать из простой проблемы сложную

    Я люблю Реакт. Люблю за то, как он работает. За то, что он делает вещи «правильно». HOC, Composition, RenderProps, Stateless, Stateful – миллион патернов и антипатернов которые помогают меньше косячить.

    И вот совсем недавно React принес нам очередной подарок. Очередную возможность косячить меньше — getDeviredStateFromProps.

    Технически — имея статический мапинг из пропсов в стейт логика приложения должна стать более проста, более понятна, тестируема и так далее. По факту многие люди начали топать ногами, и требовать prevProps обратно, не в силах (или без особого желания) переделать логику своего приложения.

    В общем разверлись пучины ада. Ранее простая задача стала сложней.



    Изначальная дискуссия развернулась на страницах github/reactjs.org, и была вызвана необходимостью знать как именно поменялись props, в целях логирования
    We have found a scenario where the removal of componentWillReceiveProps will encourage us to write worse code than we currently do.
    // OLD WAY
    componentWillReceiveProps(newProps){
          if (this.props.visible === true && newProps.visible === false) {
               registerLog('dialog is hidden'); 
          }
    }
    // NEW WAY
    static getDerivedStateFromProps(nextProps, prevState){
            if (this.state.visible === true && nextProps.visible === false) {
               registerLog('dialog is hidden'); 
           }
            return {
                   visible : nextProps.visible
            };
    }
    

    PS: Но вы то знаете, что такие операции надо выполнять в `componentDidUpdate`?

    Но это было только начало. В тот же день был (пере)создан issue о модификации getDerivedStateFromProps, потому что без prevProps жизни нет никакой. Точно такой же issue уже был единожды закрыт с «Wont fix», и на этот раз, после долгих словестных баталей, он опять же был закрыт с «Wont fix». Так ему и надо.
    Но, перед тем как обсудить выход из положения, и почему issue был закрыт — лучше придумать какой-либо удобный пример для наглядности рассуждений.

    Таблица. С сортировкой и постраничной навигацией


    Обратимся к TDD, и в начале определим задачу, и пути ее решения

    1. Что нужно сделать чтобы нарисовать таблицу?
      1. Взять данные для отображения
      2. Отсортировать их
      3. Взять slice, с данными только для текущей страницы
      4. Не перепутать порядок пунктов

    2. Что делать если данные изменились?
      1. Начать все с начала

    3. А если изменилась только страница?
      1. Выполнить пункт 1.3 и далее.

    4. Как изменить страницу
      1. this.setState({page})

    5. Как отреагировать на изменение state.page?
      1. Никак


    В том и проблема — можно отреагировать на изменение props, но для изменение стейта такой функции нет (даже если вы прочитали ее в названии этой статьи).

    Правильное решение номер 1


    Точнее «правильное» решение. Я думаю это должен быть конечный автомат. Изначально он находится в состоянии idle. При поступлении сигнала setState({page}) он перейдет в другое состояние — changing page. При входе в это состояние он посчитает что там ему надо и пошлет сигнал setState({temporalResult}). По хорошему далее автомат должен перейти в состояние «next step», который посчитает все что угодно из шага после текущего, и в итоге попадает в commit, и где передаст данные из temporalResult в data, после чего перейти в idle.

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

    Правильное решение номер 2


    А что если создать еще один элемент, в который передать в виде пропсов state и props из текущего элемента, и использовать getDerivedStateFromProps?

    Тоесть «первый» компонент — это «smart» controller, в котором происходит setState({page}), а его dumb будет не такая уж и dump, вычисляя нужные данные при изменении внешних параметров.
    Все хорошо, но пункт «пересчитать только то что нужно» не реализуем, так как мы ЗНАЕМ что что-то изменилось (потому что кто-то вызвал getDerivedStateFromProps), но не знаем ЧТО.
    В этом плане не изменилось ни-че-го.

    Правильное решение номер 3 («официальное»)


    Основой «решения», которое и послужило аргументаций закрытия issue, было одно простое утверждение.
    You might not need redux getDerivedStateFromProps. You need memoization.
    // base - https://github.com/reactjs/rfcs/pull/40#discussion_r180818891
    import memoize from "lodash.memoize";
    
    class Example {
      getSortedData = memoize((list, sortFn) => list.slice().sort(sortFn))
      getPagedData = memoize((list, page) => list.slice(page*10, (page+1)*10))
    
      render() {
        const sorted = this.getSortedData(this.props.data, this.props.sort);
        const pages = this.getPagedData(sorted, this.props.page);
    
        // Render with this.props, this.state, and derived values ...
      }
    }
    

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

    Но тут есть две проблемы. И обе я взял из второго комментария к оригинальному issue

    Проблема номер 1


    I'm having to resort to a weird multi-depth WeakMap, and making decisions about when to drop different levels of the cache.
    Тот самый «значимый» порядок изменения значений, помноженный на кривые руки. Появляются какие-то уровни кеширования, WeakMaps. Охо, что ты делаешь, остановись!

    Проблема номер 2


    One solution suggested memoizing that computation and calling it each time, which is a good idea but in practice it means managing caches which, when you're dealing with a function that takes more than one argument, greatly increases your surface area for potential bugs and mistakes.
    А это одна из главных проблем всех библиотек мемоизации — требования использования «конечных» значений как аргументов функции. В общем просто неудобно, а заодно можно переменну перепутать.

    Первая проблема имеет решение в reselect. В каскадах reselect, когда имея два мемоизированных значения на вход, можно сформировать третье мемоизированное значение на выход.

    Еще лучше — композиция мемоизированных функций, когда вы просто определяете порядок исполнения, а некий (конечный) автомат исполняет их одно за другим… Вообще каскады reselect это тоже «composing», но у них там дерево, а тут нужен линейный процесс — waterfall.
    Хм, я видел водопад в анонсе этот статьи. К чему бы это?
      const input = {...this.state, ...this.props };
      const resultOfStep1 = {...input, sorted:this.getSortedData(input.data, input.sort);
      const resultOfStep1 = {... resultOfStep1, sorted:this.getPagedData(resultOfStep1.sorted, resultOfStep1.page);
    

    Если «весь мусор» вынести в хеплер, то получим достаточно чистый код

    const Flow = (input, fns) => fns.reduce( (acc,fn) => ({...acc, ...fn(acc)}), input);
    
      const result = Flow({...this.state, ...this.props },[
        ({ data, sort }) => ({data: this.getSortedData(data, sort) });
        ({ data, page }) => ({data: this.getPagedData(data, page)
      ]);
    

    Чистое, простое и очень красивое решение для проблемы номер 1, четко определяющее порядок формирования конечного значение, которое совершенно не возможно мемоизировать.

    Которое совершенно не возможно мемоизировать потому что у «шага» исполнения только один аргумент, и при любом изменении input надо начинать с самого первого этапа — нельзя понять что изменился только page и надо перезапустить только последний шаг.

    Или можно?


    import {MemoizedFlow} from "react-memoize";
    
    class Example {
      getSortedData = (list, sortFn) => list.slice().sort(sortFn)
      getPagedData = (list, page) => list.slice(page*10, (page+1)*10))
    
      render() {
        return (
           <MemoizedFlow
            input={this.props}
            flow = [
              ({data, sort}) => ({ data: this.getSortedData(data, sort)}),
              ({data, page}) => ({ data: this.getPagedData(sorted, page)});
            ]
           >{ ({data}) => <table>this is data you are looking for {data}</table> }
           </MemoizedFlow>    
        )  
      }
    }
    

    Как не странно — на этот раз все будет работать как часики. И даже функция Flow, которая будет использована для расчета конечного значения будет точно такая же, как и раньше.
    Весь секрет — в другой функции мемоизации, memoize-state, про которую я расказывал месяц назад — она то и знает какие части state были использованны на конкретном этапе, давая возможность реальзовать мемоизированный waterfall.
    Более сложный пример на поиграться — codesandbox.io/s/23ykx5z5jp
    В итоге — статическая функция getDerivedStateFromProps заменяется на (в неком смысле) статически определенный компонент, настройка которого позволяет четко определить «способ и метод» получения результата, точнее формирование конечного результата из набора исходных данных.

    Это может быть getDerivedStateFromProps, getDerivedStateFromState, getDerivedPropsFromProps — все что угодно. Можно даже сайдэффекты запускать (работает, но лучше не надо).

    И самое главное — такой подход позволяет определить именно реацию на изменение параметра. И позволяет определить именно в том виде который «правильный»
    Данные надо обновить если изменились даные, или страница. А не только если «страница».
    Однаждый определенный Flow невозможно сломать. Главное перестать хотеть знать старые значения.

    Заключение


    В общем React последнее время учит нас «не хотеть» различные подходы, которые могут привести к говнокоду, или проблемам с асинхронным рендером. Но люди остаются людьми, и не хотят отказываться от старых, проверенных временем подходов. Именно в этом и проблема.

    На самом деле иногда очень сложно понять как сегодня «правильно» готовить реакт, ведь буквально две недели назад вы его готовили, а тут БАЦ и рецепт изменился.

    Но не отчаивайтесь — memoize-state и react-memoize построенный на его основе немного притупят болевые ощущения. Все проблемы можно решить, главное просто попытаться взглянуть на проблему под другим углом.

    PS: Тот самый оригинальный issue с заключением.
    PS: Немного про то как и почему memoize-state работает.
    Поделиться публикацией
    Ой, у вас баннер убежал!

    Ну, и что?
    Реклама
    Комментарии 14
    • 0
      «Более сложный пример» подозрительно напоминает reducer засунутый прямо во view и занимающий там явно много места. Можно было вынести весь этот list/sort и прочее в redux и сопокойно пользоваться getDerivedStateFromProps + actions.
      • 0
        Это, как не странно, может быть сложная задача. Проблема в именно в «Flow».
        Чтобы правильно (и оптимально, что важно) все посчитать redux должен по некому события сделать «что должен», после чего как-то тригернуть следуйщий этап. В редьсерах такого не сделать.
        Быть может сага? В принципе она может ожидать прихода событий и вызывать функции обработчики, а они уже будут вызывать известные им этапы и будет все работать просто и удобно. (надо будет написать хелпер для такого, спасибо за идею)
        А что делать если послали два события? Тогда все посчитается два раза, чтобы этого избежать, то прийдется «для расчета следуйщего этапа» тоже кидать событые, и надется что takeLatest сделает все правильно.

        В общем я постарался решить задачу без redux, потому что не redux-ом единым. В нем хорошо хранить данные, но переключение направления сортировки или страницы — личное дело компонента отображения.
        • 0
          +1. Палкой по рукам бить надо, когда логику во view layer пихают.
          Сами люди из facebook пишут, что getDerivedStateFromProps в крайне редких случаях должен использоваться.
          • +1
            Мне казалось, что вопрос о том что React не является view layer давно закрыт. Как и вопрос об уместности постоянного использования redux.
            Он вроде как должен помогать решать сложные задачи работы со стейтом, но тут нет стейта — только результат который надо показать на основе текущего стейта.
            Всегда и везде это было в mapStateToProps, на стороне view(в react-redux), но никак не в redux-core.
            Reselect composition в mapStateToProps сделает тоже самое, что и Reselect composition описанная в этой статье, только в возможно более «правильном» месте и будет болеть теми же самыми проблемами. Заменить reselect на memoize-state(он для того и был придуман) — и дело в шляпе.
        • 0
          Эх, я так и не понял зачем в данных ситуациях пытались юзать componentWillReceiveProps. ((
          Если делать пейджер то все что он должен «знать» это сырые данные (props.allRows), и номер отображаемой страницы (state). Вроде все. Какие еще state.visible могут быть у пейджера? это же задача вьюхи самой страницы.
          • 0
            state.visible — это копипаст конкретной боли конкретного человека из конкретного issue. Разговор переходит на задачу с таблицей парой абзацев ниже.
          • +2

            Как ни пытался — не получилось уловить всю нить повествования. Что, где, как, почему? ;) Я так и не понял в чём там вообще была проблема. Но как я понял, поплевавшись на вложенные WeakMap-ы мы пришли к Proxy с трекингом зависимостей. Ух.


            Итак. Нам нужно взять таблицу и отсортировать. У нас есть data и есть sort. Мемоизируем это тем же createSelector-ом (можно и не им, не принципиально):


            const getSortedTable = createSelector(
              obj => obj.data,
              obj => obj.sort,
              (data, sort) => magic(data, sort));
            
            const sortedData = getSortedTable({ data, sort });

            Каждый холостой вызов getSortedTable будут вызваны две крохотные функции и будет произведено три ===. Затем что нам надо? Взять slice? Ну ок:


            const getPageData = createSelector(
              obj => obj.data,
              obj => obj.page,
              (data, page) => anotherMagic(data, page));
            
            const sortedData = getPageData({ data: sortedData, page });

            Картинка та же: два () => select и три ===. Зачем тут weakMap-ы? Зачем тут proxy?


            kashey где я потерял нить и свернул не туда?


            P.S. вложенные weakmap-ы крутая штука, но явно не в этой задаче.

            • 0
              Нить нигде не потеряна, и поворот не пройден. Только кода получилось примерно в 6 раз больше чем в первом примере на мемоизацию.
                getSortedData = (lodash)memoize(magic)
                getPagedData = memoize(anotherMagic)
              
                render() {
                  const sorted = this.getSortedData(this.props.data, this.props.sort);
                  const pages = this.getPagedData(sorted, this.props.page);
                }
              

              Вся проблема в том как это написать так чтобы было просто, удобно и всем понятно. В случае с reselect или обычной мемозаиций — каждый шаг понятен, но не понятно как они сочетаются.
              Тут — сильно понятнее и короче. И декларативнее.
              memoizedFlow([
                  ({data, sort}) => ({ data: magic(data, sort)}),
                  ({data, page}) => ({ data: anotherMagic(sorted, page)});
              ])
              

              WeakMapов тут нет, это какой-то ботаник начал их городить непонятно зачем и почему. И proxy тут нет, так как работать должно под IE11/React Native. (на самом деле есть и то и другое, но не всегда)

              А скорость? memoize-state просто знает какие ключи были использованы для формирования результата и проверяет их. При этом достаточно умно, понимая вложеность обьектов и как вообще «современная иммутабельность» работает. Я к тому, что при холостой работе там будет те же самые два ===, под одному на каждую функцию. В общем perf тесты часть репозитория, согластно измерениям там сотни тысяч, а то и миллионы мемоизированных операций в секунду. Чуть чуть медленее reselect или memoize-one.
              • 0
                но не понятно как они сочетаются

                А почему непонятно? У тебя же тут по сути всего 2 строки (пример с lodash/memoize), как в них можно запутаться? :) Ну или спрошу по-другому: неужели весь этот код, что в разделе "Или можно?" с <MemoizedFlow/>, клеевым-редьюсером и function-as-children проще и понятнее, этих двух строк? :) Что мы выиграли непосредственно в этом примере с таблицей.

                • 0
                  Это тут все понятно. А если вы приходите на готовый проект (или уходите с такого) — было бы хорошо иметь совсем-совсем понятную логику.
                  В принципе все так называемые «code smells» и антипатерны примерно об этом — оно вообще работает, но рано или поздно все сломается. Когда новый джун выйдет, например.
                  У меня за джуна выступает жена, для которой до сих пор составляет проблему написать js, c правильным reselect она 100% не справится, в то время как такая вот развесистая конструкция у нее проблем не вызывает.
                  • –1
                    А почему не использовать memoizedFlow из memoize-state прямо в getDeviredStateFromProps? Суть остается та же, зато render() не захламляется.
                    • 0
                      Основых идей две:
                      — все думаю перенести все дела из getDeviredStateFromState в componentDidUpdate, потому что второй представляет и данных побольше, и сайдэффекты «разрешает».
                      — зачем писать свой getDeviredStateFromProps если он вам не нужен, и вообще у вас Stateless компонент?

                      Но на самом деле — почему бы и не использовать прямо в render. Проблем нет.
                      • 0
                        Ну это как-то странно. Во-первых, переносить в componentDidUpdate и обновлять там state — значит рендерить два раза. Во-вторых, MemoizedFlow хоть и позволяет в некоторых случаях писать компонент без стейта, все же представляет собой стейт, хоть и неявный. Так что stateless'ом тут не очень пахнет.
                        • 0
                          Ну вот потому и не переношу :)
                          А про stateless — MemoizedFlow сам то stateful, но никак не регламентирует чем должны быть «вы».

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

            Самое читаемое