Читая документацию по react-redux v7, вы могли обратить внимание на предупреждение о проблеме устаревших пропсов и дочерних зомби-компонентов. Этот раздел может показаться слегка запутанным, если читатель ещё не сталкивался с проблемой сам. Цель данной статьи — как следует разобраться с проблемой устаревших пропсов и её решением в react-redux.

Статья является переводом материала «Stale props and zombie children in Redux», впервые опубликованного в блоге Кай Хао, и, хотя автор скромно отзывается о себе как об исследователе-любителе и просит не считать себя экспертом, статья является одним из самых подробных материалов по теме. Кроме того, ссылка на оригинал уже включена в официальную документацию по react-redux.

Дисклеймер: статья предполагает, что читатель уже имел дело с React, Redux и react-redux. Мы не будем подробно останавливаться на описании API этих библиотек и оставим это дело официальной документации. Совершенно не обязательно читать это, чтобы использовать Redux, однако никогда не будет лишним разобраться как работают твои инструменты под капотом — не говоря о том, что это просто интересно.

Разбираемся с react-redux

Чтобы понять в чём проблема, для начала надо разобраться с тем, как работает Redux, или, если точнее, сам react-redux. Мы попробуем сделать это, самостоятельно реализовав основной функционал Redux и react-redux. Поскольку мы ограничиваемся исключительно образовательными целями, мы не станем реализовывать с нуля каждую деталь их функционала и внутренних оптимизаций — только минимум, необходимый, чтобы воспроизвести проблему, которую мы решаем.

По своей сути, Redux — это модель подписок, применяющая архитектуру flux для управления глобальным состоянием приложения. В чистом JavaScript подписки обычно реализуются с помощью обработчиков событий. В Redux мы подписываемся на изменения стора, и при изменении состояния внутри наших редьюсеров оповещаем об этом всех подписчиков.

const createStore = (reducer, initialState = {}) => {
  let state = initialState;
  const listeners = [];

  return {
    getState() {
      return state;
    },
    subscribe(listener) {
      listeners.push(listener);

      // Возвращаем функцию отписки
      return () => {
        const index = listeners.indexOf(listener);
        listeners.splice(index, 1);
      };
    },
    dispatch(action) {
      state = reducer(state, action);

      listeners.forEach(listener => {
        listener();
      });
    },
  };
};

Выше мы видим минимальную реализацию createStore в Redux. Созданный таким образом стор может возвращать своё состояние через getState(), подписывать слушателей через subscribe(listener) и отправлять экшены через dispatch(action) — как в официальной реализации.

Следующий шаг: интегрировать всё это с React. Мы создадим компонент <Provider>, через контекст передающий стор дочерним компонентам, реализуем HOC connect для наших контейнеров, и, в конце концов, напишем хук useSelector, который в большинстве случаев заменит нам connect.

У <Provider> достаточно простой API — передаём стор, созданный через createStore вниз, используя контекст React.

const Context = React.createContext();

const Provider = ({ children, store }) => (
  <Context.Provider value={store}>
    {children}
  </Context.Provider>
);

Перед тем, как садиться за работу над connect и useSelector, давайте вернёмся к проблеме, которую мы решаем. Конкретные детали реализации Redux неразрывно связаны с его историей — изучая его эволюцию неплохо было бы сначала разобраться какие проблемы приходилось решать разработчикам.

Проблема

Давайте на секунду вернёмся к описанию нашей проблемы из документации:

Устаревшие пропсы — это ситуация, в которой:

  • Селектор использует данные, получаемые компонентом из пропсов.

  • Отправка экшена приводит к тому, что родительский компонент повторно рендерится и передаёт дочернему компоненту новые пропсы.

  • При этом селектор вызывается раньше, чем дочерний компонент смог повторно отрендериться с новыми пропсами.

Дочерние зомби-компоненты — это частный случай, в котором:

  • Вложенные компоненты, подписанные на изменения стора, монтируются при первом рендере, приводя к тому, что дочерний компонент подписывается на стор раньше родительского.

  • Отправленный экшен удаляет данные из стора: например, элемент todo-листа.

  • В результате этого, родительский компонент не рендерит дочерний компонент, связанный с удалёнными данными.

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

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

Вряд ли к этому моменту вы полностью понимаете каждый пункт из этого списка, так что постараемся оформить пример кода для демонстрации.

Пример

В качестве примера мы создадим очень простой todo-лист (очень оригинально, да), который будет рендерить список todo, а мы сможем удалить один из них, отправляя экшен DELETE.

Для начала, создадим стор и связанный редьюсер.

const reducer = (state, action) => {
  switch (action.type) {
    case 'DELETE': {
      return {
        ...state,
        todos: state.todos.filter(
          todo => todo.id !== action.payload
        ),
      };
    }
    default:
      return state;
  }
};

const store = createStore(reducer, {
  todos: [{ id: 'a', content: 'A' }],
});

Теперь давайте напишем компонент todo и обернём его в HOC connect (пока мы используем только connect и вернёмся к useSelector позже).

const Todo = ({ id, content, dispatch }) => (
  <li
    onClick={() => {
      dispatch({ type: 'DELETE', payload: id });
    }}
  >
    {content}
  </li>
);

const TodoContainer = connect((state, ownProps) => ({
  content: state.todos.find(todo => todo.id === ownProps.id)
    .content,
}))(Todo);

const TodoList = ({ todos }) => (
  <ul>
    {todos.map(todo => (
      <TodoContainer key={todo.id} id={todo.id} />
    ))}
  </ul>
);

const TodoListContainer = connect(state => ({
  todos: state.todos,
}))(TodoList);

ReactDOM.render(
  <Provider store={store}>
    <TodoListContainer />
  </Provider>,
  document.getElementById('root')
);

Мы создаём два компонента: <Todo> и <TodoList>, и оборачиваем их в HOC'и connect. Получился максимально простой пример todo-листа, написанного в архитектуре Redux — пока ничего особенного.

Мы ожидаем, что запустив приложение и кликнув по любому <Todo> из списка, мы удалим его.

Теперь, когда мы понимаем чего хотим от нашего приложения, давайте возьмёмся за работу над собственной реализацией HOC'а connect из react-redux.

Первый подход

Начнём с реализации из react-redux v4, которая была существенно проще, к тому же именно в ней API библиотеки окончательно оформился и стабилизировался. Мы сделаем свою версию HOC'а connect. В ней мы будем использовать хуки и другие современные фичи React, но по сути она будет мало отличаться от реализации на классовых компонентах. К чему нам отказываться от даров прогресса, не так ли?

// Для наглядности мы опустим реализацию `mapDispatchToProps`,
// которая практически полностью повторяет `mapStateToProps`.
// Вместо этого просто передадим `dispatch` через пропсы.
const connect = mapStateToProps => WrappedComponent => props => {
  const store = React.useContext(Context);
  const [state, setState] = React.useState(() =>
    mapStateToProps(store.getState(), props)
  );
  const propsRef = React.useRef();
  propsRef.current = props;

  React.useEffect(() => {
    return store.subscribe(() => {
      setState(
        mapStateToProps(store.getState(), propsRef.current)
      );
    });
  }, [store, setState, propsRef]);

  return (
    <WrappedComponent
      {...props}
      {...state}
      dispatch={store.dispatch}
    />
  );
};

Это максимально простая реализация connect без всяких оптимизаций.

Давайте кликнем по элементу списка и убедимся, что он удалится. Хм, окей, всё сломалось, ничего не работает. Но что пошло не так?

Давайте по шагам проследим всю цепочку событий, поработав интерпретатором JavaScript, и посмотрим что могло произойти.

  1. После первого рендера и <TodoList>, и <Todo> подписываются на события стора внутри useEffect. Из-за того, что useEffect (как и componentDidMount) выполняются снизу дерева компонентов вверх, <Todo> подписывается первым, а <TodoList> — после него.

  2. Пользователь кликает по <Todo>, отправляя в стор экшен DELETE, и ожидает, что элемент списка будет удалён.

  3. Стор получает экшен и передаёт его в редьюсер: теперь список todo — это пустой массив { todos: [] }.

  4. После этого стор начинает оповещать подписанные компоненты об изменении. <Todo> получает оповещение первым т.к. он первым подписался.

  5. connect, в который обёрнут <Todo>, вызывает mapStateToProps с актуальным состоянием стора (store.getState()) и текущими пропсами (propsRef.current).

  6. Но в сторе к этому моменту не осталось todo, и попытка обратиться к ним через state.todos[ownProps.id] возвращает undefined.

  7. Обращение к (undefined).content приводит к ошибке.

Перед нами та самая проблема дочерних зомби-компонентов во всей красе. Изменение состояния после отправки экшена в Redux происходит синхронно, а рендеринг — нет. Когда мы обращаемся к ownProps внутри mapStateToProps, наши пропсы потенциально могут иметь устаревшее значение. По схожей причине (одной из) setState вызывается асинхронно; управление состоянием извне React часто оказывается связано с подводными камнями, которые нужно уметь обходить.

И всё-таки, как нам это починить? Если проблема в том, что мы храним состояние вовне React, можем ли мы перенести его вовнутрь? Мы хотим, чтобы пропсы всегда были актуальными, а это происходит только когда React вызывает повторный рендер компонента. Почему бы так и не поступить? Мы можем переместить вызов mapStateToProps в этап рендеринга, а в случае необходимости вызвать повторный рендер компонента мы сможем из нашего колбэка подписки на стор.

const connect = mapStateToProps => WrappedComponent => props => {
  const store = React.useContext(Context);
  const [, forceUpdate] = React.useReducer(c => c + 1, 0);
  const state = mapStateToProps(store.getState(), props);

  React.useEffect(() => {
    return store.subscribe(() => {
      forceUpdate();
    });
  }, [store, forceUpdate]);

  return (
    <WrappedComponent
      {...props}
      {...state}
      dispatch={store.dispatch}
    />
  );
};

Теперь элемент успешно удаляется при клике по нему. Ура!

Чуть позже к нам подходит менеджер и спрашивает можем ли мы отложить удаление элемента на секунду — нужно всего лишь не удалять элемент сразу после клика, а делать это секундой позже.

Хм, окей, звучит просто! Ведь так?

const Todo = ({ id, content, dispatch }) => (
  <li
    onClick={() => {
       // dispatch({ type: 'DELETE', payload: id });
      
       setTimeout(() => {
         dispatch({ type: 'DELETE', payload: id });
       }, 1000);
    }}
  >
    {content}
  </li>
);

Мы настолько уверены, что всё заработает, что написав код, сразу коммитим его и выкатываем на прод, даже не тестируя (чего, вообще-то, делать никогда не стоит). Моментом позже чаты разрываются от жалоб, все в панике. Приложение сломано: через секунду после клика пользователя всё падает.

unstable_batchedUpdates

Почему добавление простого setTimeout приводит к падению всего приложения? Чтобы разобраться с этим багом, придётся вернуться назад и заново всё проверить. Мы могли бы добавить пачку console.log в код и посмотреть, что они вернут, но я сэкономлю немного времени и сразу покажу результат. Первые 4 шага не изменились, так что начинаем с 5-го.

До добавления setTimeout:

  1. connect, в который обёрнут <Todo>, получает оповещение об изменении в сторе и вызывает forceUpdate(), чтобы запланировать повторный рендер.

  2. connect, в который обёрнут <TodoList>, получает оповещение об изменении в сторе и вызывает forceUpdate(), чтобы запланировать повторный рендер.

  3. <TodoList> рендерится с пустым массивом [] из стора и отрисовывает пустой ul. Наш <Todo> не рендерится.

Ошибок нет — всё выглядит как надо. Теперь давайте посмотрим, что будет, если мы решим отправлять экшен из колбэка setTimeout. Первые 4 шага также идентичны, единственная разница — секундная задержка между первым и вторым.

После добавления setTimeout:

  1. connect, в который обёрнут <Todo>, оповещается об изменении в сторе и вызывает forceUpdate(), чтобы запланировать повторный рендер.

  2. <Todo> рендерится и вызывает mapStateToProps с текущим состоянием и текущими пропсами.

  3. Из-за того, что к этому моменту родительский компонент (<TodoList>) ещё не отрендерился, пропсы в <Todo> — устаревшие, при этом состояние уже обновилось. Вызов state.todos[ownProps.id] возвращает undefined, а обращение к (undefined).content приводит к ошибке.

По сути две эти ситуации отличаются 6-м шагом. В первом примере сначала вызывается колбэк подписки в родительском компоненте (<TodoList>), а во втором в этот момент рендерится дочерний компонент (<Todo>). Выглядит так, как будто <Todo> синхронно перерендеривается сразу после вызова forceUpdate()!

«Стоп, я думал setState работает асинхронно?» И да, и нет. В большинстве случаев setState действительно асинхронный, но только тогда, когда он вызывается из обработчиков событий React. React убедится, что собрал все обновления, произошедшие в обработчиках, и асинхронно перерендерит компонент со всеми изменениями сразу. Поместив setState в колбэк setTimeout'а, мы лишились этой фичи и сделали setState синхронным.

В примере выше React группирует вызов forceUpdate() внутри <Todo> и вызов forceUpdate() внутри <TodoList>, и рендерит их одновременно. Ещё одно важное замечание: React рендерит дерево компонентов сверху дерева вниз. Поэтому родительский компонент (<TodoList>) отрендерится первым, и пропустит рендеринг <Todo>.

К счастью, в будущем React скорее всего сделает все вызовы setState асинхронными, а значит, даже если мы поместим setState внутрь setTimeout, обновления всё равно будут сгруппированы.

Получается остаётся только ждать этого прекрасного момента? Совсем нет! Есть и другой путь, чтобы исправить всё прямо сейчас.

В React, или в react-dom, если быть точнее, есть скрытая функция unstable_batchedUpdates, которая делает как раз то, что нам нужно: она убеждается, что обновления будут сгруппированы вместе. Обработчики событий внутри React уже используют эту фичу под капотом — именно поэтому внутри обработчика setState выполняется асинхронно. Кстати имя функции намекает нам, что использовать её стоит только если мы полностью понимаем, что делаем. Считайте, что вас предупредили.

Просто обернём наш вызов dispatch в колбэк unstable_batchedUpdates.

import { unstable_batchedUpdates } from 'react-dom';

const Todo = ({ id, content, dispatch }) => (
  <li
    onClick={() => {
      setTimeout(() => {
        // dispatch({ type: 'DELETE', payload: id });
        
        unstable_batchedUpdates(() => {
          dispatch({ type: 'DELETE', payload: id });
        });
      }, 1000);
    }}
  >
    {content}
  </li>
);

Есть ещё одно место, где мы можем добавить unstable_batchedUpdates. Вместо того, чтобы оборачивать каждый вызов dispatch, просто обернём соответствующий метод стора.

dispatch(action) {
  state = reducer(state, action);

  unstable_batchedUpdates(() => {
    listeners.forEach(listener => {
      listener();
    });
  });
}

Теперь всё работает. Собственно, теперь у нас на руках реализация react-redux v4 за вычетом важных оптимизаций, таких как мемоизация возвращаемых элементов и предотвращение лишних обновлений в ситуациях, когда mapStateToProps не зависит от значения ownProps. Правда даже с этими оптимизациями в худшем случае мы всё ещё заставляем все подключённые к стору компоненты перерендериваться при каждом его изменении. Для небольшого приложения этого вполне могло бы хватить, но для библиотеки управления глобальным состоянием, спроектированной с прицелом на масштабируемость, этого очень быстро становится недостаточно.

Модель вложенных подписок

Мы хотим минимизировать перерендеры подключенных к стору компонентов, а для этого нам нужно найти способ предотвращать лишние обновления до того, как в подписчиках будет вызван forceUpdate(). Также мы хотим сохранить порядок обновления сверху вниз, чтобы не возвращаться к проблеме устаревших пропсов и дочерних зомби-компонентов.

Команда Redux нашла интересный подход к этой проблеме в react-redux v5. Применив модель вложенных подписок, мы можем предотвратить лишние обновления, и вместе с тем избежать проблемы устаревших пропсов.

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

Болтовня ничего не стоит — покажем код.

const createSubscription = () => {
  const listeners = [];

  return {
    subscribe(listener) {
      listeners.push(listener);

      // Возвращаем функцию отписки
      return () => {
        const index = listeners.indexOf(listener);
        listeners.splice(index, 1);
      };
    },
    notifyUpdates() {
      listeners.forEach(listener => {
        listener();
      });
    },
  };
};

Мы написали функцию createSubscription, которая чем-то похожа на наш старый createStore — тут тоже есть подписчики и метод subscribe. Разница в том, что сейчас мы не храним никакого состояния. Также у нас есть метод notifyUpdates(): он нужен нам для того, чтобы оповестить об изменении дочерние компоненты и вызвать их колбэки подписок — о них чуть позже.

Вы можете сказать, что это просто функция, создающая event emitter и будете полностью правы: всё действительно очень просто. Следующий шаг — написать новый HOC connect, поместив mapStateToProps внутрь колбэка подписки, чтобы мы могли предотвращать лишние обновления.

const connect = mapStateToProps => WrappedComponent => props => {
  const store = React.useContext(Context);

  const subStore = React.useMemo(
    () => ({
      ...store,
      ...createSubscription(),
    }),
    [store]
  );

  const [, forceUpdate] = React.useReducer(c => c + 1, 0);
  const stateRef = React.useRef();
  stateRef.current = mapStateToProps(
    store.getState(),
    props
  );
  const propsRef = React.useRef();
  propsRef.current = props;

  React.useEffect(() => {
    return store.subscribe(() => {
      const nextState = mapStateToProps(
        store.getState(),
        propsRef.current
      );

      if (shallowEqual(stateRef.current, nextState)) {
	      // Предотвращаем лишнее обновление и оповещаем об 
	      // изменении дочерние компоненты
        subStore.notifyUpdates();
        return;
      }

      forceUpdate();
    });
  }, [store, propsRef, stateRef, forceUpdate, subStore]);

  React.useEffect(() => {
    subStore.notifyUpdates();
  }); // Намеренно не передаём список зависимостей, чтобы эффект вызывался после каждого рендера

  return (
    <Provider store={subStore}>
      <WrappedComponent
        {...props}
        {...stateRef.current}
        dispatch={store.dispatch}
      />
    </Provider>
  );
};

Здесь много чего происходит, так что постараемся рассмотреть всё по-порядку. Базовая реализация в чём-то похожа на наш первый подход. Мы создаём дочерний стор (subStore), создавая подписку, которую мы реализовали до этого, и объединяем новый стор с нашим оригинальным стором. В результате метод subscribe переопределяет оригинальный метод из стора; кроме того, стор получает новый метод notifyUpdates.

Теперь мы вызываем mapStateToProps сразу в двух местах. Первый раз во время рендера, чтобы после вызова forceUpdate() всегда иметь актуальные пропсы. Второй раз мы используем его в нашем колбэке подписки на стор, чтобы поверхностно сравнить его результат с прошлым и предотвратить обновление, если состояние не изменилось.

В return мы возвращаем наш компонент, обёрнутый новым <Provider> — тем самым мы явно переопределяем контекст стора нашим новым subStore. В результате все компоненты ниже по дереву получат экземпляр subStore вместо оригинального стора.

Кроме того, мы добавляем ещё один эффект, после каждого рендера вызывающий subStore.notifyUpdates() для всех компонентов ниже по дереву. Подписки дочерних компонентов не сработают до момента получения актуальных пропсов при следующем рендере — таким образом, мы решили и проблему устаревших пропсов.

Кликнув по элементу списка ещё раз, мы убедимся, что он успешно удалится без всяких ошибок. Чтобы этот процесс стал чуть понятнее, можем пройти по всем шагам ещё раз, чтобы убедиться, что всё работает как задумано.

  1. После первого рендера <Todo> подписывается на изменения subStore, созданного в <TodoList> и переданного вниз через контекст.

  2. После этого <TodoList> внутри своего useEffect подписывается на изменения глобального стора, созданного в createStore.

  3. Пользователь кликает по <Todo> и отправляет экшен DELETE в стор, ожидая, что элемент списка будет удалён.

  4. Стор получает экшен и передаёт его в редьюсер: теперь список todo — это пустой массив { todos: [] }.

  5. Стор вызывает колбэки всех своих подписчиков. Сейчас на стор напрямую подписан только <TodoList>, так что его колбэк будет вызван, а колбэк <Todo> — нет.

  6. <TodoList> выполняет свой колбэк подписки на стор, а в нём — mapStateToProps с последним состоянием стора (store.getState()) и актуальными пропсами (propsRef.current).

  7. mapStateToProps возвращает новое значение, так что вызывается forceUpdate(). После этого <TodoList> вызывает mapStateToProps ещё раз на этапе рендеринга и отрисовывает пустой <ul>, потому что в сторе больше нет todo.

  8. <Todo> размонтируется и вызывает ‌unsubscribe в эффекте, удаляя свой колбэк из подписчиков subStore.

  9. <TodoList> вызывает эффект, в котором происходит subStore.notifyUpdates(), после рендера, и, т.к. к этому моменту у subStore уже нет подписчиков, процесс завершается.

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

Интересно, что в дочернем компоненте мы потенциально вызываем mapStateToProps дважды: внутри колбэка подписки на стор, и после повторного рендера родительского компонента. Второй из них произойдёт первым, но в обоих случаях состояние и пропсы будут актуальными. Для улучшения производительности мы можем мемоизировать функцию mapStateToProps, чтобы в таких ситуациях она не вызывалась дважды.

Заметьте, что в этот раз нам даже не пришлось использовать unstable_batchedUpdates в функции notifyUpdates. Обновления разделены между разными subStore, и дочерние компоненты вызовут колбэки подписок только после того, как будут отрендерены родительские компоненты — необходимость группировать обновления пропадает.

Мы обрисовали базовую идею того, как модель вложенных подписок реализована в react-redux v5 и v7 (естественно, без учёта всех оптимизаций). В текущей версии мы получили существенный рост производительности за счёт предотвращения лишних обновлений — мы больше не заставляем React каждый раз перерендеривать компоненты. Ко всему прочему, мы избавились от метода unstable_batchedUpdates, который нам достаточно неудобно использовать в react-redux (он входит в библиотеку react-dom, а react-redux может использоваться и с другими рендерерами). Что ж, уже неплохо!

Контекст React

Есть способ решить наши проблемы ещё проще — используя нативный контекст React. Мы уже пользуемся им, чтобы передавать стор, почему бы не использовать его и для оповещения подписчиков об изменениях? React-redux v6 взял этот подход за основу сразу как только React зарелизили стабильную реализацию контекста. Этот подход кажется намного проще, и, раз теперь обновления стора распространяются внутри React, мы без дополнительных усилий получаем правильный порядок оповещения об изменениях — сверху дерева вниз. Больше нам не понадобятся ни unstable_batchedUpdates, ни вложенные подписки. Число подписок на стор сокращается до одной — теперь нам не нужно подписываться на изменения стора в каждом HOC'е.

// Ради наглядности мы опять опускаем реализацию
// дополнительных оптимизаций и обработку ошибок
const Provider = ({ children, store }) => {
  const [state, setState] = React.useState(() =>
    store.getState()
  );

  React.useEffect(() => {
    return store.subscribe(() => {
      setState(store.getState());
    });
  }, [store, setState]);

  const context = React.useMemo(
    () => ({
      ...store,
      state,
    }),
    [store, state]
  );

  return (
    <Context.Provider value={context}>
      {children}
    </Context.Provider>
  );
};

const connect = mapStateToProps => WrappedComponent => props => {
  const { state, dispatch } = React.useContext(Context);

  const mappedState = mapStateToProps(state, props);

  return (
    <WrappedComponent
      {...props}
      {...mappedState}
      dispatch={dispatch}
    />
  );
};

Выглядит отлично: у нас получилась максимально простая реализация, к которой мы всё ещё можем применить оптимизации из нашей первой попытки (react-redux v4), кроме того мы в принципе не сталкиваемся с проблемой устаревших пропсов и дочерних зомби-компонентов. Подход с контекстом часто можно встретить в продуктовом коде, его же используют такие библиотеки как unstated-next. Это было бы подходящим решением, если бы мы использовали множество мелких сторов, но в Redux у нас единственный глобальный стор. В результате, в плане производительности версия с контекстом оказывается существенно хуже, и мы опять вынуждены двигаться дальше.

Помните почему мы ушли от первой реализации к модели с вложенными подписками? Всё ради того, чтобы предотвращать лишние обновления до вызова setState и перерендеринга компонента. В нашей последней реализации мы вынуждены вызывать setState и заново рендерить компонент для того, чтобы получить актуальное состояние. Только после этого мы можем вызывать mapStateToProps, чтобы получить в компоненте нужную его часть. В результате, после релиза react-redux v6 произошло несколько инцидентов с регрессией производительности. Кроме того, в команде React даже сказали, что на данный момент они не рекомендуют использовать контекст React для flux-подобного управления состоянием.

Хуки

Но контекст React не самый новый инструмент в библиотеке — теперь у нас есть ещё и хуки! React-redux v7 анонсировал новый API, основанный на хуках, который делает код намного проще и понятнее. Самый важный из новых хуков — это, пожалуй, useSelector.

Давайте перепишем наш todo-лист на хуках. Изменения затронут компоненты <Todo> и <TodoList>.

const Todo = ({ id }) => {
  const content = useSelector(
    state =>
      state.todos.find(todo => todo.id === id).content
  );
  const dispatch = useDispatch();

  return (
    <li
      onClick={() => {
        dispatch({ type: 'DELETE', payload: id });
      }}
    >
      {content}
    </li>
  );
};

const TodoList = () => {
  const todos = useSelector(state => state.todos);

  return (
    <ul>
      {todos.map(todo => (
        <Todo key={todo.id} id={todo.id} />
      ))}
    </ul>
  );
};

Нам больше не нужны контейнеры-HOC'и — с хуками мы можем получать состояние через useSelector и доступ к dispatch через useDispatch. Небольшое изменение по сравнению со старым добрым mapStateToProps: мы больше не передаём состояние через пропсы, а получаем его напрямую в useSelector. Это немного изменит то, как мы будем сравнивать старое состояние с новым в нашем setState.

useDispatch реализуется совсем просто.

const useDispatch = () =>
  React.useContext(Context).dispatch;

Без особых сложностей пишем и собственный useSelector.

const useSelector = selector => {
  const store = React.useContext(Context);
  const [, forceUpdate] = React.useReducer(c => c + 1, 0);
  const state = selector(store.getState());

  React.useEffect(() => {
    return store.subscribe(() => {
      forceUpdate();
    });
  }, [store, forceUpdate]);

  return state;
};

Тем ни менее, мы всё ещё далеки от финиша. Сейчас мы заново рендерим все подключенные к стору компоненты при любом его изменении. В реализации с хуками всё стало даже хуже — теперь у нас нет промежуточного контейнера, который мы могли достаточно дёшево перерендерить, избежав дорогого рендера обёрнутого в него компонента. Чтобы предотвратить лишние обновления, теперь нам придётся вызывать селектор внутри колбэка подписки на стор.

const useSelector = selector => {
  const store = React.useContext(Context);
  const [state, setState] = React.useState(() =>
    selector(store.getState())
  );

  React.useEffect(() => {
    return store.subscribe(() => {
      setState(selector(store.getState()));
    });
  }, [store, setState, selector]);

  return state;
};

И эта версия просто ломается. Мы опять вернулись к проблеме устаревших пропсов и дочерних зомби-компонентов, с которой всё это время боролись. Попробуем снова пройти по шагам и посмотреть, что пошло не так.

  1. После первого рендера и <TodoList>, и <Todo> подписываются на стор в своих useEffect. Из-за того, что useEffect выполняются снизу дерева компонентов вверх, <Todo> подписывается первым, а <TodoList> — после него.

  2. Пользователь кликает по <Todo>, отправляя в стор экшен DELETE, и ожидает, что элемент списка будет удалён.

  3. Стор получает экшен и передаёт его в редьюсер: теперь список todo — это пустой массив { todos: [] }.

  4. Стор вызывает колбэки всех своих подписчиков. Поскольку <Todo> подписался первым, он первым вызовет свой колбэк подписки на стор.

  5. Из-за того, что мы передаём пропсы в колбэк на этапе рендеринга, они сохранились в его замыкании. Теперь это устаревшие пропсы.

  6. Попытка получить state.todos[ownProps.id] возвращает undefined, а обращение к (undefined).content приводит к ошибке.

Давайте повторим, что нам к этому моменту известно о проблеме устаревших пропсов. Мы сталкиваемся с устаревшими пропсами при использовании синхронной модели подписок если наши дочерние компоненты используют пропсы, значение которых является производной данных из стора. У этой проблемы есть два решения:

  1. Переместить вызов селектора в этап рендеринга и использовать unstable_batchedUpdates.

  2. Использовать модель вложенных подписок.

Хуки не могут изменить дерево компонентов, а значит мы больше не можем обернуть каждый привязанный к стору компонент в <Provider>, чтобы предоставить ему дочерний экземпляр стора. Получается, второй вариант придётся вычеркнуть.

В первом варианте мы столкнёмся с падением производительности из-за того, что должны будем использовать селектор на этапе рендеринга, а это приведёт к дополнительному рендеру на каждое изменение — придётся отсекать лишние обновления внутри колбэка подписки на стор. А здесь мы в очередной раз столкнёмся с устаревшими пропсами, из-за которых при вызове колбэка возможны ошибки.

Мы остались без вариантов: ни одно из решений не подходит — кажется, придётся искать какие-то компромиссы.

Что если нам просто проигнорировать ошибку? Давайте зададимся вопросом в каких ситуациях может произойти ошибка? Грубо говоря, основных варианта всего два. Ошибка — это либо ожидаемое поведение из-за бага в самом селекторе, либо она связана с устаревшими пропсами. В любом случае, мы можем безопасно обработать её, перерендерив компонент и вызвав selector(store.getState()) на этапе рендеринга, чтобы получить актуальное состояние. В первом случае ошибка повторится, а во втором исчезнет.

А что если устаревшие пропсы не приведут к выбросу исключения? В такой ситуации мы бы остались с неконсистентным состоянием. На самом деле, из-за того, что компонент всё равно будет заново отрендерен при вызове selector(store.getState()) на этапе рендеринга — проблема решится сама собой.

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

const useSelector = selector => {
  const store = React.useContext(Context);
  const [, forceUpdate] = React.useReducer(c => c + 1, 0);
  const currentState = React.useRef();
  // Пробуем получить состояние на этапе рендеринга, 
  // чтобы к этому моменту у нас были актуальные пропсы
  currentState.current = selector(store.getState());

  React.useEffect(() => {
    return store.subscribe(() => {
      try {
        const nextState = selector(store.getState());

        if (nextState === currentState.current) {
	        // Предотвращаем лишние обновления
          return;
        }
      } catch (err) {
	      // Игнорируем ошибки
      }

      // Вызываем перерендеринг компонента
      forceUpdate();
    });
  }, [store, forceUpdate, selector, currentState]);

  return currentState.current;
};

Если вернуться к нашему трюку с unstable_batchedUpdates, мы опять сможем одновременно отсечь лишние обновления и безопасно решить проблему устаревших пропсов и дочерних зомби-компонентов. Давайте снова пробежимся по коду, чтобы убедиться, что всё работает как надо. Первые четыре шага не изменились, так что начинаем с пятого.

  1. Из-за того, что мы передаём пропсы в колбэк на этапе рендеринга, они сохраняются в его замыкании. Теперь это устаревшие пропсы. Попытка получить state.todos[ownProps.id] возвращает undefined, а обращение к (undefined).content приводит к ошибке. Мы перехватываем ошибку и пропускаем её, зная, что ещё раз попытаемся получить состояние на этапе рендеринга, и вручную запускаем перерендеринг.

  2. Из-за того, что мы используем unstable_batchedUpdates, рендеры будут сгруппированы. <TodoList> выполняет свой колбэк подписки на стор, selector(store.getState()) возвращает пустой массив, начинается перерендеринг.

  3. Рендеринг происходит сверху дерева вниз, сначала рендерится <TodoList>, он заново вызывает selector(store.getState()) и отрисовывает пустой <ul>, завершая рендеринг.

Используя этот подход, мы предполагаем, что пользовательский селектор написан с соблюдением двух правил:

  1. У селектора нет побочных эффектов.

  2. Код не должен рассчитывать на выброс исключений из селектора как на ожидаемое поведение.

Короче говоря, селектор должен быть чистой функцией. Потенциально мы можем вызывать селектор несколько раз в ходе обновлений. При условии, что селектор — чистая функция, его перезапуск не будет проблемой. Более того, активация React.StrictMode уже сейчас накладывает такие ограничения на функции, выполняемые на этапе рендеринга — соблюдение тех же правил будет хорошей практикой и для селекторов.

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

Мы могли бы дальше оптимизировать производительность нашего решения: например, второй раз вызывать селектор в фазе рендеринга только по необходимости (когда мы столкнёмся с устаревшими пропсами). Тем ни менее, к этому моменту мы уже разобрались, что под капотом у useSelector и почему селекторы должны быть чистыми функциями.

Итоги

Фух, можете похлопать себе, если добрались до этого места — это было непросто!

Какой бы тривиальной ни казалась идея написать свой Redux с нуля, на пути вас ждёт куча подводных камней, которые придётся как-то обходить. И это при том, что в статье мы в принципе не занимались оптимизациями и обработкой ошибок.

Надеюсь, что этот материал окажется полезным и поможет кому-то разобраться с тем, как Redux и react-redux устроены внутри. Хотелось бы выразить огромное уважение всем мэйнтейнерам и контрибьюторам за создание этих отличных библиотек и постоянную работу над их улучшением. Не смотря на то, что я согласен с тезисом, что зачастую вам не нужен Redux, для разработки в средних и больших командах он предоставляет удобную архитектуру.

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

Комментарии (15)


  1. markelov69
    25.08.2021 11:59

    Хорошо что я использую MobX в связке с реактом и не знаю вообще никаких проблем. А те, кто до сих пор используют Redux могу им только посочувствовать.


    1. nin-jin
      25.08.2021 13:25

      MobX вроде умеет выстраивать обновления в правильном порядке, чтобы избежать описанных тут проблем, но для этого необходимо, чтобы весь рендеринг происходил в едином авторане. И вот что-то я не уверен, что в Реакте этого можно добиться.


      1. Alexandroppolus
        26.08.2021 11:04

        На относительно простых кейсах проблема не воспроизводится

        class Store {
          constructor() {
            makeObservable(this)
          }
          @observable a = 1
          @observable p = 1
          @action incP = (): void => {
            this.p += 1
          }
          @action incA = (): void => {
            this.a += 1
          }
        }
        
        const store = new Store()
        
        const Child: React.FC<{ p: number }> = observer(({ p }) => {
          console.log(render Child, p = ${p}, store.a = ${store.a})
          return (
            <div>
              <div>Comp: props.p = {p}</div>
              <div>Comp: store.a = {store.a}</div>
            </div>
          )
        })
        
        const App: React.FC = observer(() => {
          console.log(render App, store.p = ${store.p}, store.a = ${store.a})
          return (
            <div>
              <div>App: store.a = {store.a}</div>
              <Child p={store.p} />
              <button
                type="button"
                onClick={() => {
                  store.incP()
                  store.incA()
                }}
              >increment</button>
            </div>
          )
        })

        Child, хотя и подписан на изменения стора, не бежит "впереди паровоза" и рендерится только после App с уже изменившимися значениями. Видимо, потому что в MobX подписка происходит не в useEffect, а прямо в рендере. То есть в правильном порядке.

        Но вообще, "нельзя сказать наверняка". Не исключаю, что есть вариант поймать устаревшие пропсы.


        1. nin-jin
          26.08.2021 13:45

          Ага, похоже тут всё ок, тоже не смог воспроизвести.


        1. mayorovp
          26.08.2021 17:34

          Так вы setTimeout добавьте, чтобы setState синхронным стал. И поставьте incA вперед.


          1. Alexandroppolus
            26.08.2021 17:53

            Не совсем понял, что и как поменять


            1. mayorovp
              26.08.2021 18:38

              onclick={() => setTimeout(() => {
                store.incA()
                store.incP()
              }, 100)} 

              Как-то так, если есть ошибки - прошу простить, с телефона неудобно комментарии писать.


              1. sovaz1997
                26.08.2021 23:48

                Зачем 100, если можно 0?)


                1. Alexandroppolus
                  27.08.2021 14:50

                  Спешка ни к чему)


          1. Alexandroppolus
            30.08.2021 10:59

            Да, с таймаутом "совсем другой коленкор". Или, например, с нативным онкликом по body. Реакт во время обработки события не запускает перерендер, как я понимаю.

            По сабжу, видимо, надо отслеживать не рендеры, а вызовы компутедов (оные приравниваются к редуксовым селекторам), вот там по идее можно будет поймать сабж.


      1. mayorovp
        26.08.2021 17:32

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

        Спасают от нее две вещи, и главная из них называется "обработка ошибок". С точки зрения MobX, любая реакция имеет право падать с ошибкой любое число раз, и это не должно ломать остальные реакции.

        Вторая "защита" тут - принятый подход к передаче объектов вместо идентификаторов.

        Однако, тот факт что зомби обычно не ломают программу, не означает что из-за них невозможна просадка производительности.


  1. alexesDev
    30.08.2021 10:27

    Чистый useSelector сделан в этой либе, используем у себя вместо redux.

    https://github.com/use-global-hook/use-global-hook/blob/c9f2abc1c9bc24aebcb20c4a52820588cc161c9c/index.js

    Думал, что хак с forceUpdate прям грязный, а на деле классика...


    1. Alexandroppolus
      30.08.2021 10:54

      Думал, что хак с forceUpdate прям грязный, а на деле классика...

      Хук работает "внутри" компонента, и ему доступен единственный способ заставить компонент перерендирится - через useState/useReducer. Раньше, когда Редукс подключался к компоненту функцией connect (т.е. "снаружи"), то обновлял через пропсы.


      1. alexesDev
        01.09.2021 12:59

        Понимаю, что в функциональных компонентах нет другого способа сделать forceUpdate как раньше. Но с виду это все же грязный хак, наличие которого может означать "не надо так делать" или "мы еще работаем над этим". Я только про это.


        1. jacobX Автор
          02.09.2021 01:09

          Я подозреваю, что и в дальнейшем ничего подобного реализовано не будет т.к. при использовании нативного состояния React такой проблемы просто нет. А внешние стейт-менеджеры (не только react-redux) вынуждены использовать хак, да.

          Этот хак кстати приведён прямо в официальной документации React с ремаркой, что им лучше не злоупотреблять: https://reactjs.org/docs/hooks-faq.html#is-there-something-like-forceupdate