Я люблю Реакт. Люблю за то, как он работает. За то, что он делает вещи «правильно». 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, и в начале определим задачу, и пути ее решения
- Что нужно сделать чтобы нарисовать таблицу?
- Взять данные для отображения
- Отсортировать их
- Взять slice, с данными только для текущей страницы
- Не перепутать порядок пунктов
- Что делать если данные изменились?
- Начать все с начала
- А если изменилась только страница?
- Выполнить пункт 1.3 и далее.
- Как изменить страницу
- this.setState({page})
- Как отреагировать на изменение state.page?
- Никак
В том и проблема — можно отреагировать на изменение 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
reduxgetDerivedStateFromProps. 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
Автор: Корзунов Антон