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

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

Речь пойдет о проблемах, которые могут возникать при деструктуризации данных из стора с использованием useSelector.

Влияние деструктуризации

Как мы знаем, при использовании useSelector мы передаем в него функцию-селектор, с помощью которой и выбираем нужные данные из хранилища Redux. При использовании деструктуризации мы извлекаем конкретные свойства из объекта состояния Redux. Но мы не должны забывать о главной проблеме такого подхода - если изменяется любое свойство объекта, который мы деструктуризируем (даже если это свойство не было присвоено переменной во время деструктуризации) происходят дополнительные перерисовки компонентов, даже если свойство менялось совсем в другом компоненте.

Попробуем кратко объяснить, почему так происходит. Когда мы извлекаем часть объекта из стора с использованием деструктуризации, мы создаем новый объект, который имеет ссылку отличную от деструктуризируемого объекта. При этом, когда меняются свойства объекта в сторе происходит создание нового объекта (новой ссылки) в деструктуризации, что и приводит к дополнительному перерендеру компонента.

Как избежать ненужных рендеров?

Одним из основных способов избежать данной проблемы является прямое присваивание свойств из стора переменным вместо использования деструктуризации.

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

Жмак

Рассмотрим, что же происходит в данном коде.

Изначально у нас есть три компонента, которые рендерятся при загрузке страницы. В каждом из этих компонентов присутствует получение данных из стора разным способом. При этом в сторе лежит два значения: firstCounter и secondCounter.

Компоненты

const { firstCounter } = useSelector((store) => store.counters);
console.log("render MethodOne");
const firstCounter = useSelector((store) => store.counters.firstCounter);
console.log("render MethodTwo");
const { firstCounter } = useSelector((store) => ({
    firstCounter: store.counters.firstCounter
  }));
console.log("render MethodThree");

Мы видим, что в первом и третьем компоненте используется деструктуризация двумя способами, а во втором компоненте происходит прямое присваивание.

При увеличении firstCounter можно заметить, что все работает так, как нам и нужно - происходит увеличение значения на 1 и при этом происходит по 1 рендеру в каждом компоненте.

Увеличение firstCounter на 1

Теперь попробуем увеличить secondCounter. И что же мы видим? Увеличение secondCounter привело к дополнительному рендеру первого и третьего компонента, хотя он там даже не используется. Во втором же компоненте количество рендеров осталось без изменений, из чего можно сделать вывод, что прямое присваивание устраняет данную проблему.

Увеличение secondCounter на 1

Может быть есть еще какие-то способы?

Действительно, есть еще несколько способов, которые имеют место быть.

1) Можно использовать функцию shallowEqual из пакета react-redux для сравнения объектов из стора. Она передается вторым аргументом в useSelector. Попробуем добавить эту функцию в выше приведенный код.

const { firstCounter } = useSelector(
    (store) => ({
      firstCounter: store.counters.firstCounter
    }),
    shallowEqual
  );
console.log("render MethodThree");

В данном случае действительно дополнительные рендеры были устранены.

const { firstCounter } = useSelector((store) => store.counters, shallowEqual);
console.log("render MethodOne");

Но вот в этом случае дополнительные рендеры не были устранены. Это связано с тем, что в случае с третьим компонентом мы вытаскиваем непосредственно значение firstCounter из стора и кладем его в объект под ключ firstCounter, после чего и происходит деструктуризация. И, если firstCounter не меняется в сторе, то не меняется и ссылка на него. Во втором же случае shallowEqual сравнивает ссылки объекта counters и при изменении secondCounter ссылка все равно меняется и происходит дополнительный рендер.

P.S. поправьте меня, если это не совсем так

2) Еще один из способов - это использование библиотеки reselect. В данном случае значение будет изменяться только в том случае, если оно было изменено в сторе. Приведу топорный рабочий пример, возможно, есть более удобный и рациональный способ использования, который я бы тоже был бы рад увидеть.

const getFirstCounter = createSelector(
  (store) => store.counters.firstCounter,
  (firstCounter) => ({
    firstCounter
  })
);

const { firstCounter } = useSelector(getFirstCounter);

Итого

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

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

Дополнительные мысли

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

const getFirstCounter = (state) => state.counters.firstCounter;
const getSecondCounter = (state) => state.counters.secondCounter;

...

const firstCounter = useSelector(getFirstCounter);

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


  1. SiSya
    00.00.0000 00:00

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

    Дело в том, что мне года два назад пришлось из компонента прокидывать параметр в селектор таким образом, чтобы данные компонента аффектили результат. С разбегу параметризованный селектор провоцировал ререндер даже в случае идентичного примитивного целевого значения. Если не ошибаюсь, в самом селекторе обновлялась ссылка на него же. Точно не вспомню. Пришлось попотеть, чтобы решить проблему.

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


    1. idmx Автор
      00.00.0000 00:00

      Спасибо за наводку) Интересный кейс, сам с таким не сталкивался. В будущем постараюсь разобрать его и, в случае чего, написать статью, так как действительно звучит как проблема, хоть и редкая, но необходимая к освещению


  1. Gary_Ihar
    00.00.0000 00:00
    +1

    Как избежать ненужных ререндеров?

    1) npm uninstall redux
    2) npm i mobx


  1. Svetozarpnz
    00.00.0000 00:00

    Спасибо, автор, за то, что осветил не такую уж и очевидную утечку производительности компонентов. Весь реакт основан на оптимизации перерендера, поэтому стоит учитывать материал статьи в проектированию кода реакт-компонентов.

    Сам я исповедую идею "каждому используемому значению стора свой селектор". Для примера из статьи это выглядит так

    // файл селекторов

    export const counterSelector = (state) => state.counters;
    export const firstCounterSelector = (state) => counterSelector(state).firstCounter;


    // файл компонента
    const firstCounter = useSelector(firstCounterSelector);

    Мне не очень нравится идея вставлять везде shallowEqual. По крайней мере не стоит делать это бездумно.

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

    Например, так:
    // компонент деструктурирует все значения объекта
    const { isLoading, error, data } = useSelector(someDataSelector);


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


    1. idmx Автор
      00.00.0000 00:00

      Рад, что моя статья вам понравилась, спасибо за добрые слова)

      Насчет деструктуризации при использовании всего объекта. Да, вы в какой-то мере правы. Но я думаю, что в проекте лучше все держать в одних принципах и одном синтаксисе. Сейчас деструктуризация не мешает, а завтра надо будет отрефакторить или изменить логику и уже используется не весь объект и все равно менять использование.