На тему мемоизации написано довольно много статей, и все они хорошо раскрывают суть. Но мне часто не хватало шпаргалки, которую можно отправить на вопрос «А как мемоизировать?». В статье речь пойдет исключительно о функциональных компонентах.

Жизненный цикл компонента

Функциональный компонент — это обычная JavaScript-функция, и часто при обсуждении, когда говорят «компонент рендерится», имеют в виду само выполнение этой функции.

При создании компонента, когда функция выполняется впервые, говорят, что компонент «рендерится». А когда в компоненте что-то изменилось, то говорят, что компонент «перерендеривается».

С изначальным рендерингом все хорошо, это необходимый шаг, чтобы компонент создался. Но вот перерендеры могут быть лишними, и с ними можно (а иногда нужно) бороться.

Когда компонент будет перерендерен?

Есть всего несколько сценариев, когда компонент будет перерендерен:

  • Изменилось значение в useState, useReducer.

  • Изменился контекст, а наш компонент потребляет этот контекст через useContext.

  • Перерендерился родитель.

Как бороться?

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

Как бороться с useState/useReducer?

Если компонент перерендеривается из-за изменений значения в useState (или useReducer), то скорее всего, с таким перерендером не нужно бороться, так как новое значение нужно показать пользователю.

Пример, когда значение нам нужно сразу показать:

const CounterComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <div>{count}</div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

Но если мы не используем значение, а только где-то храним, то от лишних перерендеров можно избавиться с помощью useRef:

❌ Плохой пример, в котором будут лишние перерендеры:

const CounterComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => alert(count)}> Show count </button>
    </div>
  );
};

✅ Хороший пример, в котором лишних перерендеров не будет:

const CounterComponent = () => {
  const countRef = useRef(0);

  return (
    <div>
      <button onClick={() => countRef.current++}>Increment</button>
      <button onClick={() => alert(countRef.current)}> Show count </button>
    </div>
  );
};

Как бороться с useContext?

Когда компонент использует useContext, могут быть такие-же проблемы перерендера, как и в случае useState/useReducer.

Один из способов борьбы - использовать useRef. Но часто, проблема может быть в том, что например, контекст - это большой объект, и в нем много данных:

const BigContext = React.createContext({
  field1: 1,
  field2: 2,
  field3: 3,
  field4: 4,
  field5: 5,
});

const Parent = () => {
  const [state, setState] = useState({
    field1: 1,
    field2: 2,
    field3: 3,
    field4: 4,
    field5: 5,
  });

  return (
    <BigContext.Provider value={state}>
      <Child />
    </BigContext.Provider>
  );
};

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

Как бороться с перерендером из-за родителя?

В React, если родитель перерендерился, то будет перерендерен и дочерний компонент.

Например:

const ParentComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <div>{count}</div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <ChildComponent />
    </div>
  );
};

const ChildComponent = () => {
  return <div />;
};

Как только мы нажмем на кнопку, то перерендерится ParentComponent, а с ним и ChildComponent. Но это же опасно, ведь если перерендерится корневой компонент, например App, то он перерендерит все дерево компонентов.

В борьбе с таким видом перерендера, нам поможет memo:

const ParentComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <div>{count}</div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <ChildComponent />
    </div>
  );
};

const ChildComponent = memo(() => {
  return <div />;
});

А работает memo следующим образом:

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

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

Рассмотрим пример:

const ParentComponent = () => {
  const [count, setCount] = useState(0);

  const onButtonClick = () => {
    setCount(count + 1);
  };

  return (
    <div>
      <div>{count}</div>
      <ButtonComponent onClick={onButtonClick} />
    </div>
  );
};

const ButtonComponent = memo(({ onClick }) => {
  return <button onClick={onClick}>Click me!</button>;
});

Как только будет нажата кнопка, счетчик изменится, и компонент ParentComponent будет перерендерен. А будет ли перерендерен ButtonComponent? Так как он завернут в memo, сначала будет проверено, изменились ли пропсы. Так как у нас onButtonClick каждый раз создается заново, это означает, что пропсы при сравнении будут разные.

Для этого можно использовать useCallback.

const onButtonClick = useCallback(() => {
  setCount(count + 1);
}, []);

Но так, count всегда будет равен нулю, потому что в этом случае, у нас useCallback запомнит первую переданную функцию, и у этой функции лексическое окружение тоже запомнится (иначе говоря замкнется). И тогда count в этой функции будет всегда равен нулю.

Это поведение можно исправить так:

const onButtonClick = useCallback(() => {
  setCount((c) => c + 1);
}, []);

Но что, если нам нужно делать в функции другие действия, связанные с переменной, например:

const onButtonClick = useCallback(() => {
  console.log(count);
}, []);

Тогда снова сталкиваемся с проблемой, когда count остается равным нулю.

Мы можем избавиться от проблемы, указав count в зависимостях:

const onButtonClick = useCallback(() => {
  console.log(count);
}, [count]);

Но тогда, при изменении count-а в onButtonClick будет новая функция, и тогда теряется вся полезность от useCallback.

Решить это можно так:

const countRef = React.useRef(count);
countRef.current = count;
const onButtonClick = useCallback(() => {
  console.log(countRef.current);
}, []);

Мы создаем ref, и на каждом перерендере в него сохраняем актуальное значение count-а. И теперь, onButtonClick всегда будет иметь доступ к актуальному значению, и мы получаем преимущество мемоизации, onButtonClick - всегда одна и та же функция.

Но выглядит это костыльно, и если переменных много, придется создавать много ref-ов.

А что если в ref писать не значение, а саму функцию, с ее лексическим окружением?

Кастомный хук useCallbackRefed

Можно использовать такой кастомный хук, который дает нам прелести мемоизации функции, и одновременно у нас всегда актуальные значения:

const useCallbackRefed = (callback) => {
  const ref = useRef(callback);
  ref.current = callback;

  return useCallback((...args) => {
    return ref.current(...args);
  }, []);
};

Пример использования хука:

const onButtonClick = useCallbackRefed(() => {
  console.log(count);
});

А еще, коллбэк, завернутый в этот хук можно безопасно использовать в useEffect, ведь функция всегда одна и та же, а значение count всегда будет в ней актуальное:

const listener = useCallbackRefed(() => {
  console.log(count); // Всегда актуально!
});

useEffect(() => {
  if (!ref.current) {
    return;
  }

  ref.current.addEventListener("mouseover", listener);
  return () => {
    ref.current.removeEventListener("mouseover", listener);
  };
}, []);

Выводы

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

А как мы знаем, преждевременная оптимизация — корень всех зол.

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


  1. MisterAnx
    20.02.2025 07:03

    Как человек который вообще не знает React, статья даже была понятно, и теперь я задумался а если перерендер дочерних элементов на моей фреймворке. Так же на собесе могу кратко ответить зачем мемоизация, вдруг когда то перейду на react. Спасибо!


  1. LyuMih
    20.02.2025 07:03

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

    Силы и время, потраченное на бездумное написание useRef/useCallback/memo в каждом компоненте чаще всего только вредят.
    Нагрузка на чтение кода с useRef/memo/useCallback возрастает в разы, а связанные с этим баги (забыли пропс в зависимости добавить и т.п.) трудно отловить сразу.

    Пишите простой код, пока ваше приложение не начнёт явно замедляться. Потом лучше сделать рефакторинг и пересобрать композицию компонентов, чтобы созависимых компонентов было меньше.
    P.S. React 19 движется в этом направлении - React Compiler


  1. Riim
    20.02.2025 07:03

    Удивителен путь которым пошёл современный фронтенд. Как будто не хватало всей ущербности VDOM с его dirty checking-ом, так нужно было ещё придумать функциональные компоненты с их необходимостью обмазываться мемоизацией на каждый чих. Теперь бесконечно боремся и превозмогаем, а если надоело, тоже не проблема, вон выше у человека не тормозит, значит и у пользователя не тормозит и батарейка на телефоне не жрётся, пусть перерендеривается сколько хочет, это теперь норма. Ощущение, как будто смотрю репортаж из психушки.


  1. fransua
    20.02.2025 07:03

    Все эти решения полумеры, в большом приложении будет много изменений, которые иногда должны запускать рендер, иногда нет, в зависимости от фазы луны, курса доллара и десятка настроек. И правильно сказано выше, что когнитивная нагрузка растет очень быстро.
    Решения для этого существуют давно - стейт менеджеры: mobx, jotai, redux в конце концов. Можно долго холиварить, какой из них плох, но я не встречал никого, кто скажет, что лучше useState+useContext чем самый ужасный state manager.


    1. sovaz1997
      20.02.2025 07:03

      И какую же именно проблему решит стейт-менеджер?


      1. markelov69
        20.02.2025 07:03

        И какую же именно проблему решит стейт-менеджер?

        MobX полностью снимает проблему лишних перерендеров + повышает производительность, и всё это из коробки и за бесплатно, не надо расплачиваться за это говнокодом. Ну а бонусом нативный код и ноль бойлерплейта.

        Вот тут все наглядно видно:
        https://stackblitz.com/edit/stackblitz-starters-eowyumpg?file=src%2FApp.tsx

        Нажмите на консоль и увидите что рендер происходит только в тех компонентах, в которых нужно


        1. BruTO8000 Автор
          20.02.2025 07:03

          Круто, мне нравится) Только вот есть один нюанс, если мы посмотрим исходники, то увидим:
          Вот тут https://github.com/mobxjs/mobx/blob/main/packages/mobx-react/src/observer.tsx#L30 вызов передается observer-у, который в свою очередь просто мемоизирует компонент:
          https://github.com/mobxjs/mobx/blob/main/packages/mobx-react-lite/src/observer.ts#L160

          То есть memo используется всегда! Насколько это хорошо? Наверное, если бы было очень хорошо, то разработчики React-а бы сделали компоненты мемоизированными по умолчанию. А есть у вас еще аргументы в пользу Mobx?


          1. clerik_r
            20.02.2025 07:03

            То есть memo используется всегда!

            Всё правильно, в этом и весь смысл

            Насколько это хорошо?

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

            Наверное, если бы было очень хорошо, то разработчики React-а бы сделали компоненты мемоизированными по умолчанию.

            Они бы рады, и это бы работало, если бы в реакте не существовало такой вещи как React.Context, т.к. там всё топорно, компонент родитель просто не может знать когда контекст изменился, поэтому никаких memo по умолчанию быть не может.

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

            Вот пример все завернуто в контекст, но ререндерятся только те, кто должен:
            https://stackblitz.com/edit/stackblitz-starters-b9zww5vj?file=src%2FApp.tsx

            А есть у вас еще аргументы в пользу Mobx?

            Ну вам показали код, вы на него посмотрели, неужели самого этого кода уже не достаточно? Он же просто тупо нативный.

            Ну а так обычно все по классике:
            - Нативный код
            - Минимум лишней когнитивной нагрузки
            - Минимум кода(отсутствие лишней лапшы)
            - Всё оптимизировано из коробки, про оптимизации можно забыть, я уже больше 7 лет о них не думаю, все летает.
            - Не привязан к реакту, можно читать/изменять переменные откуда угодно


            1. BruTO8000 Автор
              20.02.2025 07:03

              Для использования реакта в связке с MobX это отлично

              Да, потому что MobX сам решает когда перерендерить. То есть, у нас теперь есть memo, который на каждый рендер будет сравнивать пропсы, и еще сама система реактивности MobX-а, которая не бесплатна.

              А по аргументам

              Нативный код

              Ну приходится изучать MobX, и уже работать с ним:

              Минимум лишней когнитивной нагрузки

              Спорный момент, где-то может быть даже легче useState, а не писать целый класс

              Минимум кода(отсутствие лишней лапшы)

              Ну тут посложнее именно в MobX, так как получается легко написать классы, которые будут в 1000 строк в высоту. И потом с ними разбираться будет ой как больно. Это в целом зависит не от инструмента, а от навыков разработчков.

              Всё оптимизировано из коробки

              Перерендеров нет, но зато есть потребление памяти. Повторюсь - реактивность не бесплатная.

              Не привязан к реакту,

              Да, это хороший пункт. И тестировать такое легче.

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


              1. clerik_r
                20.02.2025 07:03

                 То есть, у нас теперь есть memo, который на каждый рендер будет сравнивать пропсы

                Как думаете, что быстрее, сравнить пропсы или лишний перерендер сделать?

                и еще сама система реактивности MobX-а, которая не бесплатна.

                Да, но по сравнению с иммутабильностью как основным атрибутом на котором построено по умолчанию реактовское управление состоянием, да и в redux и т.п. тоже самое (иммутабильность). Накладные расходы на реактивность MobX'a можно назвать бесплатными и даже профицитными, потому что в совокупности факторов на реальных проектах экономят процессорное время и оперативку, да и с garbage collector'a снимают лишнюю нагрузку, иными словами мы просто не платим налог на иммутабильность.

                Ну приходится изучать MobX, и уже работать с ним:

                30 мин хватит за глаза, а если посветить ему день, то вы уже асс.
                99% случаев покрывают 2 функции:
                makeAutoObservable()
                observer()
                Остальные 1% покрывают
                reaction()
                autorun()
                when()

                А для понимаю принципов его работы достаточно знать getters/setters как работает.

                Спорный момент, где-то может быть даже легче useState, а не писать целый класс

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

                Ну тут посложнее именно в MobX, так как получается легко написать классы, которые будут в 1000 строк в высоту. И потом с ними разбираться будет ой как больно

                1) 1000 строк класса === 1000 строк в компоненте реакта, и что лучше?)
                2) Классы можно разбивать как угодно, можно использовать композиции и т.п.

                Перерендеров нет, но зато есть потребление памяти. Повторюсь - реактивность не бесплатная.

                См. пункт выше, по сравнению с налогом на иммутабильность всё в ажуре наоборот. Тем более я специально замерял в браузере (Вкладка Pefromance в DevTools) потребление больших продакшен приложений которые мы писали с MobX'ом, цифры не отличаются от любых других сайтов и все в диапазоне 85Mb находится. Ну и главное не забывать руками нажимать Collect Garbage чтобы не попадать в фазы луны срабатывания gc.

                В сухом остатке потребление памяти даже если какое-то и есть, то накладывает минимум оверхеда. Поэтому аргумент не аргумент.

                Но называть решения приведенные в статье полумерами, как-то некорректно.

                Ну по сути React + MobX, по сравнению с голым react это просто небо и земля. Поэтому не использовать MobX это принудительно лишать себя возможности лучшей жизни. Поэтому решения в статье это фактически костыли и борьба с реактом, которых можно элементарно избежать просто добавив MobX. Опять же не вижу не единого смысла страдать) Ну в целом я то и не страдаю, да и проблемы описанные в статье для меня никогда проблемами не были из-за MobX'a)


  1. khalid95
    20.02.2025 07:03

    Информативно и без воды!!!


  1. shuga2704
    20.02.2025 07:03

    Небольшое уточнение по поводу причин ререндера: их на самом деле всего одна - useReducer и его упрощенная производная useState. Контекст сам по себе не имеет никакого отношения к ререндерам (мы можете прокинуть в него обычную let-переменную, объявленную на уровне модуля и изменять ее, и вы увидите, что ререндера не произойдет, хотя контекст присутствует).
    И сам ререндер родителя это лишь следствие того, что я написал выше. Поэтому можно говорить, что он влияет лишь косвенно.
    Отсюда вывод: одной единственной причиной ререндера является изменение состояния.


    1. BruTO8000 Автор
      20.02.2025 07:03

      Если так мыслить, то мы можем просто ответить — перерендер, следствие изменения состояния. Да, это так и есть, но какую пользу дает такая интерпретация? А вот думать о причинах перерендерах так, как я представил в статье, очень полезно. Так сразу можно понять, какую стратегию оптимизации выбрать.


  1. izibrizi2
    20.02.2025 07:03

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