Я люблю Реакт. Люблю за то, как он работает. За то, что он делает вещи «правильно». 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)


  1. baldrs
    16.04.2018 08:41
    -1

    «Более сложный пример» подозрительно напоминает reducer засунутый прямо во view и занимающий там явно много места. Можно было вынести весь этот list/sort и прочее в redux и сопокойно пользоваться getDerivedStateFromProps + actions.


    1. kashey Автор
      16.04.2018 09:14

      Это, как не странно, может быть сложная задача. Проблема в именно в «Flow».
      Чтобы правильно (и оптимально, что важно) все посчитать redux должен по некому события сделать «что должен», после чего как-то тригернуть следуйщий этап. В редьсерах такого не сделать.
      Быть может сага? В принципе она может ожидать прихода событий и вызывать функции обработчики, а они уже будут вызывать известные им этапы и будет все работать просто и удобно. (надо будет написать хелпер для такого, спасибо за идею)
      А что делать если послали два события? Тогда все посчитается два раза, чтобы этого избежать, то прийдется «для расчета следуйщего этапа» тоже кидать событые, и надется что takeLatest сделает все правильно.

      В общем я постарался решить задачу без redux, потому что не redux-ом единым. В нем хорошо хранить данные, но переключение направления сортировки или страницы — личное дело компонента отображения.


    1. Miraage
      16.04.2018 10:29

      +1. Палкой по рукам бить надо, когда логику во view layer пихают.
      Сами люди из facebook пишут, что getDerivedStateFromProps в крайне редких случаях должен использоваться.


      1. kashey Автор
        16.04.2018 10:54
        +1

        Мне казалось, что вопрос о том что React не является view layer давно закрыт. Как и вопрос об уместности постоянного использования redux.
        Он вроде как должен помогать решать сложные задачи работы со стейтом, но тут нет стейта — только результат который надо показать на основе текущего стейта.
        Всегда и везде это было в mapStateToProps, на стороне view(в react-redux), но никак не в redux-core.
        Reselect composition в mapStateToProps сделает тоже самое, что и Reselect composition описанная в этой статье, только в возможно более «правильном» месте и будет болеть теми же самыми проблемами. Заменить reselect на memoize-state(он для того и был придуман) — и дело в шляпе.


  1. stopwaiting
    16.04.2018 10:54

    Эх, я так и не понял зачем в данных ситуациях пытались юзать componentWillReceiveProps. ((
    Если делать пейджер то все что он должен «знать» это сырые данные (props.allRows), и номер отображаемой страницы (state). Вроде все. Какие еще state.visible могут быть у пейджера? это же задача вьюхи самой страницы.


    1. kashey Автор
      16.04.2018 10:55

      state.visible — это копипаст конкретной боли конкретного человека из конкретного issue. Разговор переходит на задачу с таблицей парой абзацев ниже.


  1. faiwer
    16.04.2018 14:28
    +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-ы крутая штука, но явно не в этой задаче.


    1. kashey Автор
      16.04.2018 14:45

      Нить нигде не потеряна, и поворот не пройден. Только кода получилось примерно в 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.


      1. faiwer
        16.04.2018 14:54

        но не понятно как они сочетаются

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


        1. kashey Автор
          16.04.2018 15:10

          Это тут все понятно. А если вы приходите на готовый проект (или уходите с такого) — было бы хорошо иметь совсем-совсем понятную логику.
          В принципе все так называемые «code smells» и антипатерны примерно об этом — оно вообще работает, но рано или поздно все сломается. Когда новый джун выйдет, например.
          У меня за джуна выступает жена, для которой до сих пор составляет проблему написать js, c правильным reselect она 100% не справится, в то время как такая вот развесистая конструкция у нее проблем не вызывает.


          1. 8bitjoey
            17.04.2018 12:13

            А почему не использовать memoizedFlow из memoize-state прямо в getDeviredStateFromProps? Суть остается та же, зато render() не захламляется.


            1. kashey Автор
              18.04.2018 01:37

              Основых идей две:
              — все думаю перенести все дела из getDeviredStateFromState в componentDidUpdate, потому что второй представляет и данных побольше, и сайдэффекты «разрешает».
              — зачем писать свой getDeviredStateFromProps если он вам не нужен, и вообще у вас Stateless компонент?

              Но на самом деле — почему бы и не использовать прямо в render. Проблем нет.


              1. 8bitjoey
                18.04.2018 01:45

                Ну это как-то странно. Во-первых, переносить в componentDidUpdate и обновлять там state — значит рендерить два раза. Во-вторых, MemoizedFlow хоть и позволяет в некоторых случаях писать компонент без стейта, все же представляет собой стейт, хоть и неявный. Так что stateless'ом тут не очень пахнет.


                1. kashey Автор
                  18.04.2018 01:49

                  Ну вот потому и не переношу :)
                  А про stateless — MemoizedFlow сам то stateful, но никак не регламентирует чем должны быть «вы».