AI generated art of React logo

useCallback - React не так прост, как он нем думают

May 29, 20247 min read
  • #react
  • #programming
  • С недавнего времени я переметнулся из лагеря Angular в React. В то время как сравнивать эти технологии лоб в лоб имхо дело неблагодарное, в инфо-сфере есть давно сложившееся мнение, что React - минималистичный, но зато простой и элегантный, а Angular - batteries included, но зато сложный и грузный. Без опыта работы с React мне с этим мнением было делать нечего, но теперь это изменилось. Я не согласен с тем, что React - прост, и `useCallback` - мое доказательство.

    Что такое UI в представлении реакта

    В рамках поста The Two Reacts Дан Абрамов формулирует образ мышления, стоящий за React, как что-то двойственное: с одной стороны UI - это производная стейта приложения, с другой - это производная данных, полученных с сервера. Это действительно звучит просто и элегантно особенно после однозначного перехода с компонентов-классов на компоненты-функции, ведь термин “функция” буквально означает “производная”.

    В бложике я использую реактовский JSX, чтобы делить элементы интерфейса на переиспользуемые компоненты. Чтобы минимизировать количество клиентского JS, сервер рендерит весь этот React в HTML строку и возвращет только ее. Хендлер запроса по факту состоит из сбора необходимых данных и передачи их дереву higher-order JSX функций. В такой системе легко понять что, где и когда происходит: поиск нужной части кода всегда начинается сверху и идет строго вниз по дереву без необходимости прыгать по соседним веткам вправо или влево, единственное по-настоящему необходимое для работы понятие - это “функция”/“производная”, больше ничего.

    Недавно же я начал работать над небольшим пет-проектом и выбрал уже полноценный клиентский React в качестве основы, потому что тут есть необходимость в куда более интерактивном интерфейсе, чем в случае с бложиком. Практически сразу я наткнулся на то, парадигмы “UI - это производная стейта / данных с сервера” недостаточно, реальный код очень быстро перестает в нее помещаться.

    Первый пример: работает правильно, но есть излишняя нагрузка

    export const Counter: FC = () => {
      const [counter, setCounter] = useState(0);
    
      const logCounter = () => {
        console.log(`Count is: ${counter}`);
      };
    
      const increaseCounter = () => {
        setCounter(counter + 1);
      };
    
      return (
        <div className="flex flex-col gap-2 items-start">
          <p>{counter}</p>
          <p>{doubleCounter}</p>
          <button onClick={increaseCounter}>Increase Counter</button>
          <button onClick={logCounter}>Log Counter</button>
        </div>
      );
    };

    Возьмем за пример компонент из скрина. Компонент имеет в себе стейт, чтобы хранить текущее значение счетчика, див, чтобы показать его, и пару кнопок, чтобы как-то работать с этим всем. Все максимально прямолинейно, но любой JS разработчик сразу заметит тут проблемы с производительностью. В нынешней имплементации каждый цикл рендера компонента, т.е. вызов функции Counter, создает новые инстансы функций logCounter и increaseCounter. Код этих функций не меняется от рендера к рендеру, но инстансы из предыдущих рендеров отправляются в garbage collector, что нагружает браузер. Допустим у нас одновременно 100 таких компонентов отображено на экране, это значит что помимо 100 инстансов самого Counter мы храним в памяти 100 инстансов logCounter и 100 increaseCounter итого 300 переменных. Каждый ренендер - это уже чистка 300 переменных и создание 300 новых переменных (если, конечно, все 100 компонентов были изменены между рендерами). Авторы React прекрасно понимают эту проблему, поэтому они предоставляют решение в качестве встроенного хука useCallback, который сохраняет инстанс функции между рендерами.

    Второй пример: работает неправильно, но нагрузка минимальная

    export const Counter: FC = () => {
      const [counter, setCounter] = useState(0);
    
      const logCounter = useCallback(() => {
        console.log(`Count is: ${counter}`);
      }, []);
    
      const increaseCounter = useCallback(() => {
        setCounter(counter + 1);
      }, []);
    
      return (
        <div className="flex flex-col gap-2 items-start">
          <p>{counter}</p>
          <p>{doubleCounter}</p>
          <button onClick={increaseCounter}>Increase Counter</button>
          <button onClick={logCounter}>Log Counter</button>
        </div>
      );
    };

    Выглядит это примерно так. Теперь уже любой разработчик, знакомый с React, будет бить тревогу: пустые массивы зависимостей useCallback (из-за которых инстансы logCounter и increaseCounter создаются лишь однажды)! Суть в том, что реакт хуки иммутабельны, что означает, что при каждом рендере мы получаем не только новый инстанс наших функций (без использования useCallback), но и стейт (counter и setCounter) тоже “создается” заново. Само значение стейта конечно сохраняется между рендерами, но указатели на место в памяти уже новые. Из-за замыкания без новых инстансов функций используются старые инстанты, которые используют старое значение. Для большей наглядности, если мы запустим этот компонент, то получим следующий результат:

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

    Третий пример: работает правильно, а нагрузка…

    export const Counter: FC = () => {
      const [counter, setCounter] = useState(0);
    
      const logCounter = useCallback(() => {
        console.log(`Count is: ${counter}`);
      }, [counter]);
    
      const increaseCounter = useCallback(() => {
        setCounter(counter + 1);
      }, [counter, setCounter]);
    
      return (
        <div className="flex flex-col gap-2 items-start">
          <p>{counter}</p>
          <p>{doubleCounter}</p>
          <button onClick={increaseCounter}>Increase Counter</button>
          <button onClick={logCounter}>Log Counter</button>
        </div>
      );
    };

    Вот мы и дошли до качественно написанного реактовского компонента, но если оглянуться назад, то можно заметить, что что-то теперь не так. Почему мы должны пересоздавать функции (или же другими словами поведение) компонента при изменении его стейта?!

    ”А ручки-то вот они!” ©️

    В JS наследование реализовано через прототипирование. Допустим, мы создали класс Person с полями firstName и lastName и методом getFullName. Каждый инстанс этого класса имеет свои собственные firstName и lastName. Однако в то же время все они ссылаются на один единственный getFullName, который живет на прототипе нашего класса, а не на конкретном инстансе. Как же этот отдельно стоящий getFullName знает, что он вызван в контексте инстанса person1, а не person2? При помощи this.! Но React отказался от компонентов в виде классов давным давно, поэтому у нас нет this. в компонентах-функциях, поэтому авторам реакта пришлось находить решение в массиве зависимостей хука useCallback. И мне это кое-что напоминает.

    Допустим, у нас есть како-то другой стейт, который зависит от counter. Назовем его doubleCounter, он по определению должен обновляться каждый раз, когда обновляется counter и React отлично с этим справляется посредством useMemo хука. А теперь сравните схематически использование useCallback и useMemo. Они одинаковы с точки зрения компонента! increaseCounter и logCounter по сути отвечают за описание поведения компонента, но он к ним относится будто они очередной его стейт (как doubleCounter), который надо обновлять исходя из зависимостей, и который надо отдельно хранить в каждом инстансе компонента!

    export const Counter: FC = () => {
      const [counter, setCounter] = useState(0);
    
      const doubleCounter = useMemo(() => counter * 2, [counter]);
    
      const logCounter = useCallback(() => {
        console.log(`Count is: ${counter}`);
      }, [counter]);
    
      const increaseCounter = useCallback(() => {
        setCounter(counter + 1);
      }, [counter, setCounter]);
    
      return (
        <div className="flex flex-col gap-2 items-start">
          <p>{counter}</p>
          <p>{doubleCounter}</p>
          <button onClick={increaseCounter}>Increase Counter</button>
          <button onClick={logCounter}>Log Counter</button>
        </div>
      );
    };

    Я не знаю, какие проблемы возникали у авторов реакта, из-за которых они решили полностью отказаться от компонентов-классов. С удовольствием послушаю про это, если тут есть кто-то сведующий. Но это не меняет нашей реальности, в которой компонент - это не только производная от стейта или данных, но еще и поведение, связанное с ним. Отрицание этого будь-то осознанное или нет приводит к тому, что на бумаге терминов вроде как меньше (нет ни директив, ни сервисов, ни модулей как в том же Angular), но на практике в этот ограниченный набор категорий приходится засовывать ровно тоже количество понятий. Less is not always more


    Upd. по теме:

    Оказывается, между первым и последним примерами разницы в продуктивности нет (по крайней мере в плане garbage collection)…

    Суть в том как технически useCallback (а так же useState) работает:

    Когда, да, есть смысл использовать useCallback? Когда его надо передать в дочерний компонент или в какой-нибудь кастомный хук. Если дочерний компонент обернут в memo или функция упомянута в зависимостях кастомного хука, то дочерний компонент и/или кастомный хук не будут прогоняться почем зря. В родительском же компоненте разницы ноль.

    То есть как ни крути все равно лишние десятки и сотни инстансов функций будут создаваться и тут же чистится через garbage collection. Я вообще удивлен, как подобная парадигма может быть продуктивной, но, да, стоит по-внимательнее читать доки😕