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

в 1:17, , рубрики: 16.3, getDerivedStateFromProps, javascript, memoize, React, ReactJS

Я люблю Реакт. Люблю за то, как он работает. За то, что он делает вещи «правильно». HOC, Composition, RenderProps, Stateless, Stateful – миллион патернов и антипатернов которые помогают меньше косячить.
И вот совсем недавно React принес нам очередной подарок. Очередную возможность косячить меньше — getDeviredStateFromProps.
Технически — имея статический мапинг из пропсов в стейт логика приложения должна стать более проста, более понятна, тестируема и так далее. По факту многие люди начали топать ногами, и требовать prevProps обратно, не в силах (или без особого желания) переделать логику своего приложения.
В общем разверлись пучины ада. Ранее простая задача стала сложней.
getDerivedStateFromState – или как сделать из простой проблемы сложную - 1

Изначальная дискуссия развернулась на страницах 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 с заключением — github.com/reactjs/rfcs/pull/40#discussion_r180818891
PS: Немного про то как и почему memoize-state работает — habrahabr.ru/post/350562

Автор: Корзунов Антон

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js