Казалось бы зачем рассказывать о Redux в 2020ом году. Ведь есть столько замечательных альтернатив на поприще стейт-менеджеров (например). Ведь есть с десяток причин не любить Redux, о которых исписано немало статей, и прозвучало немало докладов. Однако кое-чего у него не отнять — на нём можно написать большой, функциональный, поддерживаемый и быстрый веб-сайт. Ниже я расскажу о приёмах, которые помогают это сделать с использованием react-redux. Интересно? Добро пожаловать под кат.

Дисклеймер. Для человека внимательно читавшего документацию срыва покровов не произойдет. Ваш капитан.
Лично я люблю Redux. За простоту и минимализм. Библиотека не делает никакой магии. Там где нет магии, нет возможности случайно что-то сломать, не зная, что именно ты сломал. Контроль возвращается в руки программиста, что с одной стороны развязывает руки, а с другой требует понимания при использовании. Речь ниже пойдет, как раз о таком "понимающем" использовании — приёмах, которые на первых порах требуют осознания и дисциплины, зато потом воспринимаются как что-то естественное.
Про store и state
Кратко про эти два понятия, чтобы не возникало путаницы.
Store в redux — это некоторый объект, который содержит state приложения и имеет несколько дополнительный функций, таких как функция взятия state, функция подписки на изменение в state, функция-диспатчер для событий. Иногда ниже я буду срываться на англицизм "стор"
State в redux-store согласно документации, как правило, объект с глубокой вложенностью, который, как правило, должен легко сериализовываться. State содержит данные приложения. Иногда ниже я буду срываться на англицизм "стейт"
Вводная про mapStateToProps
Функция connect, как следует из документации, является HOC-ом, предоставляющим подключение к store для компонента. Первым аргументом connect принимает функцию mapStateToProps. mapStateToProps обеспечивает подключение непосредственно к стейту приложения. Возвращать mapStateToProps обязан плоский объект (или функцию, но об этом позже).
Сов��т 1 Старайтесь избегать вложенных массивов и объектов в качестве результата mapStateToProps.Иными словами, вместо
const mapStateToProps = () => { return { units: [1, 2, 3] } }
пишите
const UNITS = [1, 2, 3]; const mapStateToProps = () => { return { units: UNITS } }
Дело в том, что react-redux хоть и простая библиотека, но заботится об оптимизации. При всяком изменении стора mapStateToProps будет вызван. Результат исполнения mapStateToProps будет сравнён с предыдущим результатом исполнения функцией shallowEqual (по умолчанию). Т.о. содержимое объекта будет сравниваться поле за полем по ссылке или по значению, и если будет найдено различие, то подключённый к стору компонент будет перерисован.
Очевидно, что в предыдущем примере в первом случае на каждое изменение стора создается новый массив [1, 2, 3], который не пройдет сравнение по shallowEqual с предыдущим таким же массивом. Данные не поменялись, а перерисовка есть.
В общем и целом, если в return-объекте mapStateToProps присутствует вложенный объект, то это повод насторожиться. Пример выше, хоть и встречается в реальной жизни, всё-таки немного надуманный. Обычно вместо UNITS будет какое-нибудь selectUserUnits(state, userId). Что подводит нас к концепции селекторов.
reselect
Т.к. state — это просто вложенный объект, то несложно осуществлять навигацию по нему через точку: state.todos[42].title. Его также несложно покрыть типами, от чего подобная навигация станет только надёжней. Однако, удобно закрывать прямой доступ к стейту. Непосредственно сам доступ осуществляется через специальные функции, которые в терминах redux принято называть селекторами.
reselect — это библиотека, которая примечательна тремя вещами. Во-первых, readme этой библиотеки в два десятка раз больше по объему, чем её код. Во-вторых, reselect предоставляет удобный способ создавать и комбинировать селекторы. В-третьих, эта библиотека позволяет оптимизировать количество вычислений (как следствие, скорость приложения) за счёт кеширования.
Совет 2 Используйте reselect там, где происходят многократные вычисления.
Тут вроде понятно. Условно, если вам надо выполнить сортировку, то имеет смысл закеширивать результат, а не выполнять вычисление на каждый вызов mapStateToProps.
Также reselect может быть полезен в контексте первого примера. Допустим, у нас есть следующие селекторы:
export const selectPriceWithDiscountByProductId = (state, id) => { const {price, discount} = state.product[id]; return {price, discount}; }; export const selectTagsByProductId = (state, id) => state.product[id].tags || [];
Первый будет вызывать перерисовку всякий раз, когда его результат попадает в результаты mapStateToProps, потому что этот селектор всегда создает новый объект. Второй вызывает перерисовку для тех продуктов, у которых нет поля tags. И тот, и другой селектор имеет смысл мемоизировать с помощью reselect (как правильно напомнил faiwer в комментариях к посту — большое ему спасибо:) ).
Впрочем, с деоптимизацией второго селектора можно справиться и другим образом:
const EMPTY_ARRAY = Immutable([]); export const selectTagsByProductId = (state, id) => state.product[id].tags || EMPTY_ARRAY;
Совет 3 Не используйте reselect там, где не происходит вычислений.
Тут тоже понятно. Не надо лишний раз засорять память. const selectUserById = (state, id) => state.user[id] трудно ускорить, и кеширование тут не поможет
Кеширование у reselect довольно прямолинейное. Селектор, созданный функцией createSelector, запоминает последний результат исполнения. Тут надо бы поподробнее познакомиться с её интерфейсом
createSelector(...inputSelectors | [inputSelectors], resultFunc)
inputSelectors — селекторы, который будут вызваны над аргументами получившегося селектора. Их результаты будут переданы в resultFunc, которая вычислит результат. Например
// selectors.js const selectUserTagById = (state, userId) => state.user[userId].tag; const selectPictures = state => state.picture; const selectRelatedPictureIds = createSelector( selectUserTagById, selectPictures, (tag, pictures) => ( Object.values(pictures) .filter(picture => picture.tags.includes(tag)) .map(picture => picture.id) ) )
В примере выше, фану ради, я разместил аж две фильтрации, да ещё и целиком по коллекции. Не уверен, что такой код имеет место быть в реальном проекте.
Итак, как reselect понимает, что пора отдавать из кеша:
- Если аргументы селектора не изменились (по
shallowEqual), то отдаем из кеша. В нашем примере, если стейт и id пользователя не изменились, то reselect отдаст предыдущий результат - Если не изменились результаты
inputSelectors(поshallowEqual), то отдаем из кеша. В нашем примере, если стейт всё-таки поменялся, то reselect вызоветselectUserTagByIdиselectPictures. Если они вернут неизменённую коллекциюpicturesи тот жеtag, то reselect отдаст предыдущий результат.
Совет 4 Без нужды не меняйте аргументы селекторов.
selectUser({user}) обеспечит регулярное вычисление как минимум для inputSelectors
А вот теперь нюанс. Память у reselect, как у рыбки: кеш длины 1. Это имеет как приятные следствия, так и неприятные. Протекание памяти нам не грозит, но мискеш получить очень легко. Допустим, у нас есть компонент RelatedPictures, который мы подключаем к стору таким образом
// RelatedPicturesContainer.js import {connect} from 'react-redux'; import {RelatedPictures} from '../components'; import {selectRelatedPictureIds} from '../selectors'; const mapStateToProps = (state, {userId}) => { return { pictureIds: selectRelatedPictureIds(state, userId) } }; export default connect(mapStateToProps)(RelatedPictures);
а используем таким
const RelatedPicturesList = ({userIds}) => ( <div> {Array.isArray(userIds) && ( userIds.map(id => <RelatedPictureContainer userId={id} /> )} </div> )
В этом примере на каждое изменение стора содержимое компонента RelatedPicturesList будет перерисовано полностью, потому что в каждом контейнере RelatedPictureContainer функция mapStateToProps возвращает новый массив pictureIds, потому что селектор selectRelatedPictureIds получает новый userId по мере отрисовки списка. Если, конечно, RelatedPictures не обладает своими оптимизациями в духе кастомного ShouldComponentUpdate, но это за рамками статьи.
Следующий совет идет прямиком из документации reselect
Совет 5 Используйте фабричные методы для селекторов.
Выше я уже упоминал, что mapStateToProps не обязана возвращать объект, она может быть фабрикой. При создании каждого контейнера react-redux смотрит, что возвращает соответствующий mapStateToProps, и если это функция, то она уже становится настоящим mapStateToProps
// selectors.js const selectUserTagById = (state, id) => state.user[id].tag; const selectPictures = (state, id) => state.picture; const createRelatedPictureIdsSelector = () => createSelector( selectUserTagById, selectPictures, (tag, pictures) => ( Object.values(pictures) .filter(picture => picture.tags.includes(tag)) .map(picture => picture.id) ) )
// RelatedPicturesContainer.js import {connect} from 'react-redux'; import {RelatedPictures} from '../components'; import {createRelatedPictureIdsSelector} from '../selectors'; const createMapStateToProps = () => { const selectRelatedPictureIds = createRelatedPictureIdsSelector(); return (state, {userId}) => { return { pictureIds: selectRelatedPictureIds(state, userId) }; }; }; export default connect(createMapStateToProps)(RelatedPictures);
Теперь каждый RelatedPicturesContainer получил свою копию селектора selectRelatedPictureIds. У каждого из селекторов свой собственный кеш длиной 1. Селектор по-прежнему зависит от userId, но в силу особенностей отрисовки он его получает неизменным. В данном примере мы жертвуем памятью в угоду скорости вычисления. Тут важно, что при удалении контейнера из react-дерева, удалится и ссылка на объект в relatedPictureIds в памяти, а значит GC сможет всё отчистить.
Пример выше может показаться откровенной жестью. "ЧТО? Фабрики? Вы упоролись, что ли, на каждый чих такой бойлерплейт писать?". Специально ��ля таких мыслей придумана замечательная библиотека re-reselect, которая на лету умеет создавать селекторы в зависимости от входящих аргументов. Всё ещё фабрика, но не в mapStateToProps
Совет 6 С осторожностью используйте re-reselect
За удобство приходится платить. Отдавая кеширование на откуп библиотеке, вы рискуете по неосторожности загадить память кешом. Пример выше, переделанный под использование ререселекта, в состоянии выкушать всю память на инстансе, если вы, например, используете Node.js и Server Side Rendering. Ведь в браузере у вас один пользователь, да и страничка живет недолго, а на сервер приходит много пользователей, и процесс там живет долго.
connect и его друзья
До сих пор советы относились к оптимизации выхлопа mapStateToProps. Однако, connect принимает бо'льшее количество аргументов.
connect(mapStateToProps, mapDispatchToProps, mergeProps, options)
Как я уже упоминал ранее, react-redux заботится об оптимизации. Например, если mapStateToProps принимает только один аргумент (state), то он не будет вызываться на изменения пропсов компонента. То же самое касается mapDispatchToProps.
Совет 7 Не декларируйте лишний раз второй аргумент вmapStateToPropsиmapDispatchToPropи не прокидывайте лишних пропсов в компоненты обернутые connect'ом
Однако, mergeProps всегда будет вызван изменении пропсов. По умолчанию mergeProps внезапно делает простой мерж пропсов компонентов и результатов (предыдущих неизменных или новых) mapStateToProps и mapDispatchToProps. Соответственно, если изменились пропсы на входе в обертку, то по умолчанию изменятся и пропсы в компоненте под оберткой, что может привести к перерисовке. Бороться с этим можно разными методами. Например, часто встречается такая конструкция: connect(mapStateToProps, null, x => x).
В своей заботе react-redux идет ещё дальше. Например, если вместо mapStateToProps передать null, то компонент перестанет реагировать на изменение стейта (технически нет, но по сути да). Таким образом можно научить компонент диспатчить события и даже меняться от входных пропсов, но не обращать внимания на стейт. Если результат mapStateToProps совпадает с предыдущим (и пропсы не менялись), то mergeProps не будет вызван.
Наконец, четвертый аргумент options позволяет тонко подтюнить поведение connect. В частности переопределить понятие равенства новых и кешированных результатов первых трёх аргументов. По умолчанию новые и предыдущие пропсы компонента, новые и кешированные результаты mapStateToProps, mapDispatchToProps и mergeProps сравниваются по shallowEqual. Иногда удобно переопределить это поведение. Например, если нет возможности вернуть плоский объект из mapStateToProps.
Совет 8 Используйте четвертый аргументconnect, чтобы тонко настроить поведение контейнера. Будьте аккуратны также, как и при использованииshouldComponentUpdate— слишком жадное сравнение может пагубно сказаться на скорости работы приложения.
Заключение
Проблемы redux со скоростью никуда не делись. Под капотом всё также единая очередь подписчиков-контейнеров, которые оповещаются на любое изменение стейта. Однако при аккуратном использовании боль от этого недостатка можно сильно уменьшить, а то и вовсе забыть о ней.
В свою очередь React-redux — это библиотека не только про подключение компонентов к стору. Важно, что она предлагает некоторый паттерн организации кода (несколько похожий на MVVM). Следуя этому паттерну, всю возню с данными для представления можно унести в контейнеры, в том числе и работу по оптимизации компонентов. Как следствие, можно сделать свои компоненты максимально простыми, содержащими только логику отрисовки. Можно пойти дальше и полностью отказаться от shouldComponentUpdate и почти полностью от хуков (useRef слишком хорош).
Ну и в самом-самом заключении. Надеюсь, что прочитавший найдет хотя бы пару советов полезными — значит не зря писал. Всем бобра)