Приветствую, коллега! В нашей практике порой возникают ситуации, когда нужно создать список инпутов. Например, создаем инпут для фильтра по строкам. Должна быть возможность добавлять инпут, удалять существующие. Классические оптимизации тут не сработают, даже если будем использовать memo и useCallback, все равно все инпуты будут обновляться при каждом обновлении родителя. В этой статье разберем, как оптимизировать списки инпутов, разберем 2 способа мемоизации коллбеков, благодаря которым будет изменяться единственный инпут в массиве, а не сразу все. Будем использовать кеширование с помощью useMemo и паттерн event switch. Поехали!

Без оптимизации

Давайте напишем инпут списка строк без оптимизаций

Код выглядит следующим образом.

import React, { FC, useState } from "react";

export const genId = (items: { key: string }[]): string => {
  if (!items?.length) return "1";
  return (Math.max(...items?.map((c) => parseInt(c.key, 10))) + 1).toString();
};

export type StringInputProps = {
  value: string;
  onChange: (value: string) => void;
};

export const StringInput: FC<StringInputProps> = ({ value, onChange }) => {
  console.log("rerender StringInput");
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
};

type StringsInputItem = {
  key: string;
  value: string;
};

export type StringsInputProps = {
  value: StringsInputItem[];
  onChange: (value: StringsInputItem[]) => void;
};

export const StringsInput: FC<StringsInputProps> = ({ value, onChange }) => {
  const onAdd = () => {
    const newValue = [...(value || [])];
    newValue.push({ key: genId(value), value: undefined });
    onChange(newValue);
  };
  return (
    <div>
      {value?.map((item) => {
        const handleChange = (_value: string) => {
          const newValue = (value || []).map((i) =>
            i.key === item.key ? { key: item.key, value: _value } : i
          );
          onChange(newValue);
        };

        return (
          <div key={item.key}>
            <StringInput value={item.value} onChange={handleChange} />
            <button
              type="button"
              onClick={() => onChange(value?.filter((i) => i.key !== item.key))}
            >
              -
            </button>
          </div>
        );
      })}
      <div>
        <button type="button" onClick={onAdd}>
          +
        </button>
      </div>
    </div>
  );
};

export const ExampleWithoutOptimization: FC = () => {
  const [value, setValue] = useState<StringsInputItem[]>();
  return (
    <StringsInput value={value as StringsInputItem[]} onChange={setValue} />
  );
};

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

export type StringInputProps = {
  value: string;
  onChange: (value: string) => void;
};

// Принимает строковое значение и функцию изменения этого значения
export const StringInput: FC<StringInputProps> = ({ value, onChange }) => (
  <input value={value} onChange={(e) => onChange(e.target.value)} />
);

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

type StringsInputItem = {
  key: string;
  value: string;
};

export type StringsInputProps = {
  value: StringsInputItem[];
  onChange: (value: StringsInputItem[]) => void;
};

Перед отправкой на сервер, служебное поле key нужно будет удалить. И было бы здорово, чтобы наш строковый инпут принимал и возвращал только список строк, без служебных полей. Фильтрация данных перед отправкой в onChange не проблема, а вот получение value без ключей и установка правильных ключей - задача довольно не простая, а решения получаются хрупкими, поэтому я рекомендую логику подставки служебных ключей делегировать родительскому компоненту, как правило форме, которая возможно будет содержать другие инпуты и совершенно точно при вызове события onsubmit сможет без лишних усилий убрать все служебные поля. Если у вас есть другое решение - поделитесь в комментариях.

Список инпутов создается в массиве. Каждому инпуту приходится создавать прямо в массиве свой обработчик handleChange, потому что каким-то образом он должен получить key конкретного инпута.

{value?.map((item) => {
  const handleChange = (_value: string) => {
    // Создаем копию массива данных и заменяем в нем данные конкретного инпута
    const newValue = (value || []).map((i) =>
      i.key === item.key ? { key: item.key, value: _value } : i
    );
    onChange(newValue);
  };

  return (
    <div key={item.key}>
      <StringInput value={item.value} onChange={handleChange} />
      <button
        type="button"
        onClick={() => onChange(value?.filter((i) => i.key !== item.key))}
      >
        -
      </button>
    </div>
  );
})}

Кстати, вот эта запись (value || []) создает пустой массив, если еще нет никакого массива.

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

() => onChange(value?.filter((i) => i.key !== item.key))

А еще есть функция добавления нового инпута.

  const onAdd = () => {
    // создаем копию массива значений или если значение пустое, создаем пустой массив
    // Обязательно нужно создавать новый массив, иначе обновления компонентов могут не работать
    const newValue = [...(value || [])];
    // добавляем новую запись, генерируем новый id на основе предыдущих (это не обязательно, я решил выпендриться)
    newValue.push({ key: genId(value), value: undefined });

    // Устанавливаем новое значение
    onChange(newValue);
  };

Она создается вне цикла, также в нашем конкретном случае мемоизация этой функции не требуется, потому что она отправляется в html элемент. Но если бы мы использовали мемоизированную супер кастомную волшебную кнопку, достаточно обернуть коллбек в useCallback.

Разобрались с тем, как этот код работает, теперь давайте обсудим, какие у этого кода есть проблемы.

Проблема

Проблема этого кода в том, что при изменении значения любого инпута абсолютно все прочие будут обновлены. Это не проблема, когда данных мало, но когда наберется сотня пунктов - приложение начнет заметно проседать. Давайте попробуем использовать memo и useCallback для устранения этой проблемы.

Классическая оптимизация

Обернем StringInput в memo, чтобы они не обновлялись при обновлении родителя.

export const StringInput = memo<StringInputProps>(({ value, onChange }) => {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
});

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

// Здесь мемоизация не работает
<MemoizedComponent>
  <div />
<MemoizedComponent>

// И здесь мемоизация тоже не работает
<MemoizedComponent>
  <MyOtherComponent />
<MemoizedComponent>

// мемоизация сработает с примитивами
<MemoizedComponent>
  строка
<MemoizedComponent>
<MemoizedComponent>
  {123}
<MemoizedComponent>
<MemoizedComponent>
  {true}
<MemoizedComponent>

// или надо мемоизировать дочерний компонент
const memoChild = useMemo(() => <div />, []);
<MemoizedComponent>
  {memoChild}
<MemoizedComponent>

const memoChild = useMemo(() => <MyOtherComponent />, []);
<MemoizedComponent>
  {memoChild}
<MemoizedComponent>

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

Теперь надо как-то мемоизировать handleChange.

{value?.map((item) => {

  // вот этот колбек надо мемоизировать
  const handleChange = (_value: string) => {
    const newValue = (value || []).map((i) =>
      i.key === item.key ? { key: item.key, value: _value } : i
    );
    onChange(newValue);
  };

  return (
    <div key={item.key}>
      <StringInput value={item.value} onChange={handleChange} />
      <button
        type="button"
        onClick={() => onChange(value?.filter((i) => i.key !== item.key))}
      >
        -
      </button>
    </div>
  );
})}

Вынести из цикла без изменений не получится, будет не определен item.

Попробуем использовать вот такой вариант, напишем функцию, что принимает item

Теперь "можно" использовать useCallback

const handleChange = useCallback((item: StringsInputItem) => (_value: string) => {
  const newValue = (value || []).map((i) =>
    i.key === item.key ? { key: item.key, value: _value } : i
  );
  onChange(newValue);
}, [onChange]);

...

<StringInput value={item.value} onChange={handleChange(item)} />

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

Особенности замыкания в хуках

Данная проблема связана с особенностями работы всех хуков с массивом зависимостей, не только в useCallback.

Внимательные читатели могли догадаться, чтобы не было ошибки, достаточно добавить value в массив зависимостей хука useCallback.

Было

Стало

Действительно баг растворился, но код стал хуже. Теперь useCallback не работает, при каждом изменении value в handleChange будет записываться новая функция и следовательно memo тоже работать не будет. В итоге наш "оптимизированный" код работает так же как и неоптимизированный, но хуже, потому что, и memo, и useCallback тратят ресурсы впустую.

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

Почему существует проблема замыкания

Если посмотреть на код в целом, может возникнуть вопрос, почему вообще нужно добавлять value в массив зависимостей, разве оно не попадет внутрь handleChange через замыкание?

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

Функциональный компонент — это функция, которая будет вызываться каждый при обновлении компонента. Соответственно и все содержимое компонента будет создаваться заново. То есть функция будет создана заново и через замыкание будет ссылаться на все переменные компонента.

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

Так как же работают хуки?

Например, useCallback мог бы выглядеть так (это неверный код, обратите внимание на направление мысли).

let store;
const useCallback = (callback, deps) => {
  if (equal(deps, store?.deps)) return store.callback;

  store = { deps, callback };
  return callback;
}

const Component = () => {
  const handleChange =  useCallback(() => {}, [])
}

Вернемся к нашему конкретному примеру и почему же состояние компонента сбрасывается.

handleChange при монтировании получит value через замыкание. Но value будет undefined на момент создания handleChange. Именно поэтому при попытке изменить значение инпута вот здесь...

const newValue = (value || []).map(...

...получим пустой массив. Любое изменение инпута сбрасывает значение к начальному состоянию value.

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

Как же все таки устранить ошибку с value?

useRef в качестве стабильной переменной

useRef можно использовать в качестве стабильной переменной. Когда записываем значение в useRef — записываем его в пространство вне компонента и обновления компонента ему не страшны.

Сохраним значение value внутри useRef

const valueCopy = useRef(value);

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

const valueCopy = useRef(value);
valueCopy.current = value;

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

const valueCopy = useRef(value);
valueCopy.current = value;

const handleChange = useCallback((item: StringsInputItem) => (_value: string) => {
  const newValue = (valueCopy.current || []).map((i) =>
    i.key === item.key ? { key: item.key, value: _value } : i
  );
  onChange(newValue);
}, [onChange]);

Обратите внимание, eslint больше не требует добавить value в массив зависимостей.

И наш код работает как надо, ну почти.

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

Как же оптимизировать компонент?

Оптимизация и мемоизация handleChange

Существует два способа замемоизировать handleChange, это паттерн event switch и кеширование с помощью useMemo.

Event switch

Этот паттерн позволяет написать единственный коллбек для нескольких инпутов. Например у нас есть инпут адреса с полями city, house, street

const AddressInput = ({ value, onChange }) => {
  const onChangeCity = (e) => onChange({ ...value, city: e.target.value });
  const onChangeStreet = (e) => onChange({ ...value, street: e.target.value });
  const onChangeHouse = (e) => onChange({ ...value, house: e.target.value });
  return (
    <div>
      <div>
        <div>city</div>
        <input value={value?.city} onChange={onChangeCity} />
      </div>
      <div>
        <div>street</div>
        <input value={value?.street} onChange={onChangeStreet} />
      </div>
      <div>
        <div>house</div>
        <input value={value?.house} onChange={onChangeHouse} />
      </div>
    </div>
  )
}

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

const AddressInput = ({ value, onChange }) => {
  const handleChange = (e) => onChange({ ...value, [e.target.name]: e.target.value });
  return (
    <div>
      <div>
        <div>city</div>
        <input name="city" value={value?.city} onChange={handleChange} />
      </div>
      <div>
        <div>street</div>
        <input name="street" value={value?.street} onChange={handleChange} />
      </div>
      <div>
        <div>house</div>
        <input name="house" value={value?.house} onChange={handleChange} />
      </div>
    </div>
  )
}

Каждому инпуту мы добавили атрибут name, достаем его значение в handleChange с помощью e.target.name и используем его для изменения соответствующего поля.

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

Event switch для решения нашей проблемы

Чтобы решить вопрос с оптимизацией, добавим в StringInput необязательное поле id (в него будем записывать item.key) и в onChange добавим id в качестве второго аргумента.

export type StringInputProps = {
  + id?: string;
  value: string;
  - onChange: (value: string) => void;
  + onChange: (value: string, id?: string) => void;
};

// было
export const StringInput: FC<StringInputProps> = ({ value, onChange }) => (
  <input value={value} onChange={(e) => onChange(e.target.value)} />
);

// стало
export const StringInput: FC<StringInputProps> = ({ value, onChange, id }) => (
  <input value={value} onChange={(e) => onChange(e.target.value, id)} />
);

А также изменим handleChange.

Было

Стало

Обращаю внимание, можно было и item передавать в качестве второго аргумента, но id в нашем конкретном случае достаточно.

Также нужно добавить prop id в компонент.

<StringInput
  + id={item.key}
  key={item.key}
  value={item.value}
  onChange={handleChange}
/>

Результат можно посмотреть тут.

Как видите работает все прекрасно.

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

Кеширование коллбеков с использованием useMemo

Без лишней воды погрузимся в пекло разработки. Вот так выглядит кеширование с помощью useMemo.

const handleChange = useMemo(() => {
  const cache: Record<string, (value: string) => void> = {};
  
  return (id: string) => {
    // если коллбек есть в кеше, используем его
    if (cache[id]) return cache[id];

    // иначе записываем в кеш новый коллбек
    cache[id] = (_value: string) => {
      const newValue = (valueCopy.current || []).map((i) =>
        i.key === id ? { key: id, value: _value } : i
      );
      onChange(newValue);
    };

    // используем только что созданный коллбек
    return cache[id];
  };
}, [onChange]);

А вот так выглядит использование handleChange

<StringInput value={item.value} onChange={handleChange(item.key)} />

Как это работает.

При монтировании компонента вызывается useMemo и в процессе создается кеш (cache)

const cache: Record<string, (value: string) => void> = {};

Это объект, ключами которого будут id инпутов, а значением - уникальный обработчик для каждого инпута.

В результате handleChange будет функцией, которая принимает id и возвращает уникальный стабильный коллбек для каждого инпута.

Результат можно посмотреть тут.

Event Switch vs useMemo cache

Какой же способ выбрать для конкретной ситуации?

Преимущества

Недостатки

Event Switch

Единственная функция обрабатывает все компоненты списка

Требует изменения исходного компонента, либо создания компонента обертки

useMemo cache

Можно использовать с любыми компонентами, правки коснутся только родительского компонента

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

Итоги

В этой статье мы разобрались, как создавать мемоизированные коллбеки для инпутов в массиве. Подробно разобрали все сложности этого процесса. Разобрали два способа: event switch и кеширование коллбека с помощью useMemo.

Event switch требует изменения исходного инпута, либо создания компонента обертки, но единственный коллбек обрабатывает все компоненты списка.

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


Cтатья подготовлена в преддверии старта курса "React.js Developer". Недавно в рамках нового набора на курс прошел открытый урок на тему «Изоморфные React-приложения с React.js , Next.js и TRPC». На этом уроке участники научились бутстрапить полноценные легко развертываемые приложения с клиентской и серверной частью. На примере разобрали настройку сборки, процесс разработки и развертывания приложения. В итоге получили удобный набор для старта разработки любого веб-приложения на современном стеке. Если интересно, посмотрите запись этого занятия.

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