Skip to content

SelectWithTags: Maximum update depth exceeded в React 18 StrictMode #2210

Description

@elzone

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

Путь данных внутри компонентов:

  1. SelectWithTagsBaseSelectWithTagsBaseSelect
  2. BaseSelect (строка ~148): useMemo(() => processOptions(options, selected, ...), [options, selected, ...])
  3. processOptions возвращает новый массив selectedOptions при каждом вычислении
  4. selectedOptions передаётся в useMultipleSelection({ selectedItems: selectedOptions })
  5. 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;
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions