SelectWithTags: Maximum update depth exceeded в React 18 StrictMode
Версии
@alfalab/core-components: 50.1.0
@alfalab/core-components-select-with-tags: 10.1.4
react: 18.x
next: (Next.js 16 с reactStrictMode: true)
Описание проблемы
SelectWithTags с непустым пропом selected вызывает ошибку «Maximum update depth exceeded» в React 18 при включённом StrictMode. Ошибка воспроизводится только в dev-режиме (StrictMode), в production сборке проблемы нет.
Минимальное воспроизведение
import { useState } from 'react';
import { SelectWithTags } from '@alfalab/core-components/select-with-tags';
const OPTIONS = [
{ key: 'IOS', value: 'iOS' },
{ key: 'ANDROID', value: 'Android' },
];
export const Example = () => {
const [value, setValue] = useState('');
return (
<SelectWithTags
options={OPTIONS}
value={value}
onInput={(e) => setValue(e.target.value)}
onChange={() => {}}
selected={['IOS']} // ← любой непустой массив вызывает ошибку
placeholder="Platforms"
/>
);
};
При reactStrictMode: true в next.config.js (или <React.StrictMode> в обёртке) компонент падает с ошибкой:
Maximum update depth exceeded. This can happen when a component calls setState inside useEffect,
but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render.
Условия воспроизведения
- ✅
selected={['IOS']} — ошибка
- ✅
selected={['IOS', 'ANDROID']} — ошибка
- ❌
selected={[]} — работает корректно
- ❌
reactStrictMode: false — работает корректно
Ошибка воспроизводится даже с onChange={() => {}} (пустой обработчик), то есть внешние обновления состояния не являются причиной.
Анализ причины
Проблема связана с внутренним использованием downshift (useMultipleSelection).
В React 18 StrictMode компоненты монтируются дважды (mount → unmount → mount). При этом useMultipleSelection из downshift получает controlled проп selectedItems (массив OptionShape из processOptions). При double-mount downshift вызывает внутренний setState, что приводит к повторному рендеру, при котором selectedItems вычисляется заново (новая ссылка на массив из useMemo в BaseSelect), downshift снова реагирует на изменение — возникает бесконечный цикл.
Это известная проблема downshift: downshift-js/downshift#1511
Путь данных внутри компонентов:
SelectWithTags → BaseSelectWithTags → BaseSelect
BaseSelect (строка ~148): useMemo(() => processOptions(options, selected, ...), [options, selected, ...])
processOptions возвращает новый массив selectedOptions при каждом вычислении
selectedOptions передаётся в useMultipleSelection({ selectedItems: selectedOptions })
downshift реагирует на изменение ссылки selectedItems → внутренний setState → ре-рендер → новый selectedOptions → цикл
Текущий workaround
Отключение reactStrictMode в next.config.js:
const nextConfig = {
reactStrictMode: false,
};
Предложение по исправлению
Стабилизировать selectedOptions перед передачей в useMultipleSelection — например, через deep comparison (react-fast-compare уже есть в зависимостях @alfalab/core-components-select):
// В BaseSelect, после processOptions
const stableSelectedOptions = useRef(selectedOptions);
if (!isEqual(stableSelectedOptions.current, selectedOptions)) {
stableSelectedOptions.current = selectedOptions;
}
// Передавать в downshift стабильную ссылку
if (selected !== undefined) {
useMultipleSelectionProps.selectedItems = stableSelectedOptions.current;
}
SelectWithTags: Maximum update depth exceeded в React 18 StrictModeВерсии
@alfalab/core-components: 50.1.0@alfalab/core-components-select-with-tags: 10.1.4react: 18.xnext: (Next.js 16 сreactStrictMode: true)Описание проблемы
SelectWithTagsс непустым пропомselectedвызывает ошибку «Maximum update depth exceeded» в React 18 при включённомStrictMode. Ошибка воспроизводится только в dev-режиме (StrictMode), в production сборке проблемы нет.Минимальное воспроизведение
При
reactStrictMode: trueвnext.config.js(или<React.StrictMode>в обёртке) компонент падает с ошибкой:Условия воспроизведения
selected={['IOS']}— ошибкаselected={['IOS', 'ANDROID']}— ошибкаselected={[]}— работает корректноreactStrictMode: false— работает корректноОшибка воспроизводится даже с
onChange={() => {}}(пустой обработчик), то есть внешние обновления состояния не являются причиной.Анализ причины
Проблема связана с внутренним использованием
downshift(useMultipleSelection).В React 18 StrictMode компоненты монтируются дважды (mount → unmount → mount). При этом
useMultipleSelectionизdownshiftполучает controlled пропselectedItems(массивOptionShapeизprocessOptions). При double-mount downshift вызывает внутреннийsetState, что приводит к повторному рендеру, при которомselectedItemsвычисляется заново (новая ссылка на массив изuseMemoвBaseSelect), downshift снова реагирует на изменение — возникает бесконечный цикл.Это известная проблема downshift: downshift-js/downshift#1511
Путь данных внутри компонентов:
SelectWithTags→BaseSelectWithTags→BaseSelectBaseSelect(строка ~148):useMemo(() => processOptions(options, selected, ...), [options, selected, ...])processOptionsвозвращает новый массивselectedOptionsпри каждом вычисленииselectedOptionsпередаётся вuseMultipleSelection({ selectedItems: selectedOptions })downshiftреагирует на изменение ссылкиselectedItems→ внутреннийsetState→ ре-рендер → новыйselectedOptions→ циклТекущий workaround
Отключение
reactStrictModeвnext.config.js:Предложение по исправлению
Стабилизировать
selectedOptionsперед передачей вuseMultipleSelection— например, через deep comparison (react-fast-compareуже есть в зависимостях@alfalab/core-components-select):