React-redux: просто о zombie children and stale props

Вадим Бударин
5 min readMay 30, 2020

--

Сегодня хочу рассказать просто о таких проблемах как zombie children и stale props в React.

В переводе на русский:

  • zombie children: дочерние компоненты, о которых родитель ничего не знает
  • stale props: протухшие свойства - свойства, которые не являются актуальными в данный конкретный момент времени

Большинство разработчиков даже не представляют себе что это такое и когда это может возникнуть.

zombie children — давняя проблема попытки синхронизировать внешнее синхронное хранилище состояния (react-redux) с асинхронным циклом рендеринга React.

Проблема кроется в порядке возникновения события ComponentDidMount/useEffect у компонентов React при их монтировании в дерево компонент в иерархиях родитель-дети, в ситуации, когда эта связка компонент отображает структуры данных типа “список” или “дерево” и эти компоненты подписаны на изменения в источнике данных, который находится вне контекста React.

Для начала давайте рассмотрим типичный PubSub объект

function createStore(reducer) {
var state;
var listeners = [];

function getState() {
return state;
}

function subscribe(listener) {
listeners.push(listener)
return function unsubscribe() {
var index = listeners.indexOf(listener);
listeners.splice(index, 1);
}
}

function dispatch(action) {
state = reducer(state, action);
listeners.forEach(listener => listener(state));
}

dispatch({});

return { dispatch, subscribe, getState };
}

Глядя на listeners мы должны понимать одно: так как элементы массива хранятся в том порядке в котором они добавлялись — коллбэки подписчиков вызываются ровно в том порядке, в котором происходили подписки.

Теперь давайте посмотрим в каком порядке происходит монтирование компонент в иерархии компонент родитель-дети:

<A>
<B />
<C />
</A>

Если каждому компоненту в ComponentDidMount добавить запись в консоль имени монтируемого компонента, то мы увидим следующее:

mounting component B
mounting component C
mounting component A

Обратите внимание: родительский компонент А монтируется после своих детей (его метод ComponentDidMount вызывается последним)!

Рассмотрим использования redux контейнеров:

state = {
list: [
1: { id: 1, title: 'Component1', text: '...' },
2: { id: 2, title: 'Component2', text: '...' }
}
};

const List = ({ list }) => {
return list.map(item =>
<ListItemContainer id={item.id} title={item.title} />);
}

const ListContainer = connect()(List);

Если в каждом из компонентов, в методе ComponentDidMount происходит подписка subscribe на оповещение об изменении данных, то при возникновении изменений в источнике данных сначала будут вызваны коллбеки у дочерних компонент и лишь затем — у компонента-родителя.

Теперь представим, что в источнике данных мы удалили данные:

1: { id: 1, title: 'Component1', text: '...' },

Первым будет вызван коллбэк для компонента ListItemContainer с id=1 (так как он до изменения данных первым монтировался и первым подписался), компонент пойдет в источник данных за данными для отрисовки, а данных там для него уже нет!
Попытка получения данных, путем обращения

const { someProp } = store.list[1];

приведет к краху приложения с ошибкой типа “Uncaught TypeError: Cannot read property ‘1’ of undefined.” или подобной (ошибки может и не быть если сначала проверить на существование элемент в сторе, но компонент в дереве присутствует и он — зомби);

Оказывается, что компонент с id=1 в данный момент не является дочерним для компонента-контейнера ListContainer, хотя на момент возникновения изменений в источнике данных он находится в дереве DOM— зомби-ребенок!

“Zombie children” — это проблема, которая может возникнуть в React, когда дочерний компонент продолжает обновляться, несмотря на то, что его родительский компонент уже удален из дерева компонентов.

Если дочерний компонент, при этом, продолжает обновляться после того, как его родительский компонент был удален из дерева компонентов, это может привести к проблемам. Например, если дочерний компонент делает запрос на сервер или использует ресурсы, которые больше не нужны, это может вызвать утечки памяти или другие ошибки.

Проблема “zombie children” может возникнуть, когда компонент рендерит дочерние компоненты на основе своих состояний или пропсов. Если родительский компонент обновляется, но его дочерний компонент не перерисовывается, то дочерний компонент не узнает о том, что его родительский компонент был удален из дерева компонентов

С zombie children разобрались. Теперь пора выяснить что такое stale props.

Рассматривая последний пример: давайте представим что для элемента с id=1 мы в источнике данных поменяли title. Что произойдет?

Сработает коллбэк для компонента ListContainer с id=1, он начнет перерисовку и отобразит title, который был ему передан в свойствах компонентом ListContainer, до изменений в данных — title в данном случае является stale props!

Проблема “stale props” может возникнуть, если компонент использует пропсы внутри функций-коллбеков (callback functions), которые вызываются асинхронно или после обновления компонента. Например, если компонент передает коллбек в дочерний компонент и использует в этом коллбеке устаревшие пропсы, то дочерний компонент может получить некорректные данные или поведение.

Чтобы решить проблему “stale props” в React, нужно обеспечить правильную передачу пропсов в дочерние компоненты и избегать использования устаревших пропсов в асинхронных функциях-коллбеках. Вместо этого можно использовать хуки, такие как useEffect, useCallback или useMemo, чтобы обеспечить более явное управление жизненным циклом компонентов и избежать проблем с устаревшими пропсами. Кроме того, можно использовать функциональные компоненты и передавать пропсы через их аргументы, что поможет избежать путаницы с контекстами и родительскими компонентами.

Почему же многие разработчики этого не знают? Потому что эти проблемы от них тщательно скрывают!! 😊

К примеру, разработчики react-redux поступают следующим образом — оборачивают отрисовку дочерних компонент в try…catch, при возникновении ошибки — они устанавливают счетчик ошибок в 1 и вызывают перерисовку родителя. Если в результате перерисовки родителя и последующей перерисовке дочерних компонент снова возникает ошибка и счетчик > 0 — значит это не zombie children, а что-то более серьезное, поэтому они прокидывают эту ошибку наружу. Если ошибка не повторилась — это был зомби-ребенок и после перерисовки родителя он пропадет.
Есть и другой вариант — изменяют порядок подписки так, чтобы родитель всегда подписывался на изменения раньше чем дочерние компоненты.

Но, к сожалению, даже такие попытки не всегда спасают — в react-redux предупреждают, что при использовании их хуков все же могут возникать указанные проблемы с zombie children & stale props, т.к. у них происходит подписка на события стора в хуке useEffect (что равнозначно componentDidMount), но в отличие от HOCа connect - не кому исправлять порядок подписки и обрабатывать ошибки.

Пример с zombie children — https://codesandbox.io/s/42164qln37?file=/src/index.js

Во избежание этих проблем советуют:

  • Не полагаться на свойства компонента в селекторе при получении данных из источника
  • В случае если без использования свойств компонента не возможно выбрать данные из источника — пытайтесь выбирать данные безопасно: вместо state.todos[props.id].name используйте todo = state.todos[props.id для начала и затем после проверки на существование todo используйте todo.name
  • чтобы избежать появления stale props — передавайте в дочерние контейнеры только ключевые свойства, по которым осуществляется выборка всех остальных свойств компонента из источника — все свойства всегда будут свежими

При разработке своих библиотек и компонент React, разработчику всегда нужно помнить об обратном порядке генерации события жизненного цикла ComponentDidMount родителя и детей в случае использования подписок на события одного источника данных, когда данные хранятся вне контекста React, чтобы не возникали ошибки данного рода.

Но лучший совет — хранить данные внутри контекста исполнения React в хуке useState или в Context /useContext— вы никогда не столкнетесь с вышеописанными проблемами т.к. в функциональных компонентах вызов этих хуков происходит в естественном порядке — сначала у родителя, а затем — у детей.

Примером такой реализации функционала react-redux с использованием React Context и React Hooks является пакет:
@budarin/use-react-redux.

Приятной вам разработки! 😊

Источники:

--

--