Привет, друзья!

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

Структура страницы
Структура страницы

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

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

Код страницы:

const Page = ({ children }) => {
  const [count, setCount] = useState({});

  const listItems = useMemo(() => {
    return React.Children.map(children, c => {
      const { id, title } = c.props;
      return { id, title };
    });
  }, [children]);

  return (
    <>
      <div>
        {listItems.map(e => {
          return (
            <div key={e.id}>
              {e.title}
              {count[e.id]}
            </div>
          );
        })}
      </div>
      <div>
        {React.Children.map(children, c => {
          const { id } = c.props;
          const clone = React.cloneElement(c, {
            onSetCount: cnt => {
              setCount(cnts => ({ ...cnts, [id]: cnt }));
            }
          });
          return clone;
        })}
      </div>
    </>
  );
};

Все виджеты имеют плюс минус похожую структуру:

//сложный грид или список или график
const Widget = ({ onSetCount }) => {

  // делает запрос данных
  // когда данные доступны, срабатывает эффект ниже  
  
  useEffect(() => {
    onSetCount(data.length);
  }, [onSetCount, data]);
};

Композируем все вместе примерно так:

<Page>
  <Widget id="widget_key" title="Название виджета" />
  ...
</Page>

Каждому виджету мы передаем пропсы - id и title. В коде страницы мы извлекаем эти пропсы для того, чтобы сформировать компонент-меню.

const listItems = useMemo(() => {
  return React.Children.map(children, c => {
    const { id, title } = c.props;
    return { id, title };
  });
}, [children]);
<div>
  {listItems.map(e => {
    return (
      <div key={e.id}>
        {e.title}
        {count[e.id]}
      </div>
    );
  })}
</div>

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

const clone = React.cloneElement(c, {
  onSetCount: cnt => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }
});

Когда очередной виджет сообщает значение своего счетчика, мы обновляем стэйт, который представляет собой объект, ключами которого являются идентификаторы виджетов, а значениями - счетчики.

Выглядит все просто. За исключением того, что этот вариант не работает. Он просто ререндерится бесконечно. Причина этого проста. При вызове cloneElement, мы всегда передаем новую функцию (разную для каждого виджета)

const clone = React.cloneElement(c, {
  onSetCount: cnt => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }
});

В коде же виджета есть такой эффект:

useEffect(() => {
  onSetCount(data.length);
}, [onSetCount, data]);

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

Да, конечно, можно убрать onSetCount из зависимостей в useEffect, но за такое не только жизнь, но даже линтер бьет по рукам.

Давайте как-то попробуем мемоизировать передаваемый пропс.

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

const Page = ({ children }) => {
  const [count, setCount] = useState({});

  ...

  const createOnSetCount = useMemo(() => {
    return memoize(id => cnt => {
      setCount(cnts => ({ ...cnts, [id]: cnt }));
    });
  }, []);

  return (
    <>
      ...
      <div>
        {React.Children.map(children, c => {
          const { id } = c.props;
          const clone = React.cloneElement(c, {
            onSetCount: createOnSetCount(id)
          });
          return clone;
        })}
      </div>
    </>
  );
};

То есть теперь мы в onSetCount передаем единожды сконструированную функцию, свою для каждого виджета.

Оно работает, бесконечный рендер ушел. Ура. Но:

  • Код выглядит не просто для такой казалось бы простой задачи.

  • Мы на ровном месте получили зависимость от lodash (да, мы ее используем, у нас продукт живет много лет уже) за счет использования функции memoize. От lodash мы стараемся уходить. Да и вообще, зачем тянуть лишние зависимости, если можно обойтись без них.

  • Каждый виджет все равно рендерится несколько раз, а именно столько раз, сколько у нас виджетов плюс один (начальный рендер). То есть решение у нас получилось не масштабируемым.

Первым шагом избавляемся от lodash, здесь он вообще не в тему:

const Page = ({ children }) => {
  const [count, setCount] = useState({});

  ...

  const onSetCount = useCallback((id, cnt) => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }, []);

  return (
    <>
      ...
      <div>
        {React.Children.map(children, c => {
          const clone = React.cloneElement(c, {
            onSetCount
          });
          return clone;
        })}
      </div>
    </>
  );
};

Теперь в качестве пропса onSetCount в каждый виджет передается одна и та же функция. Но теперь она требует наличия двух аргументов вместо одного. Виджет обязан в нее передавать свой id.

useEffect(() => {
  onSetCount(id, data.length);
}, [onSetCount, data, id]);

Стало лучше. Для всех виджетов у нас теперь одна общая функция, передаваемая в качестве пропса onSetCount. Правда, с несколько измененной сигнатурой. Но мы не приблизились пока к нашей основной задаче - сокращению числа ререндеров.

Как мы рассуждали. У нас два пути - либо не делать cloneElement, просто рендерить children. Тогда при изменении стейта children не будут перерисовываться, так как children приходит из вне как пропс.

Либо отделить стейт. Тогда cloneElement не будет мешаться и его можно не трогать.

Попробуем первый путь - избавиться от cloneElement.

cloneElement нам здесь нужен для того, чтобы дополнительно виджету передать функцию onSetCount. Как иначе мы можем всем виджетам передать эту функцию кроме как в пропсы? Ключ к разгадке здесь лежит в слове всем. Сразу возникает в голове слово context. На самом деле, react context заслужил не самую хорошую репутацию в плане производительности, но тут, думаю, скорее дело в неаккуратном его использовании. С контекстом наш код превратился в нечто подобное:

const ApiContext = createContext();

const Page = ({ children }) => {
  const [count, setCount] = useState({});

  ...

  const onSetCount = useCallback((id, cnt) => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }, []);

  return (
    <>
      ...
      <div>
        <ApiContext.Provider value={onSetCount}>{children}</ApiContext.Provider>
      </div>
    </>
  );
};

Код виджетов теперь использует useContext

const Widget = ({ id }) => {
 const onSetCount = useContext(ApiContext);
 useEffect(() => {
    onSetCount(id, data.length);
  }, [onSetCount, data, id]);  
};

Прекрасно. Это сработало. Благодаря использованию контекста и паттерну children as props мы добились желаемого - каждый виджет рендерится ровно один раз.

"А можно все же как-то без контекста" - спросите вы. Да, конечно. И это второй путь, который был у нас в головах. Идея в том, чтобы не использовать контекст, вместо этого вынести работу со стэйтом из страницы.

Создадим для левого меню со счетчиками и названиями виджетов отдельный компонент:

const List = ({ listItems, register }) => {
  const [count, setCount] = useState({});

  const update = useCallback((id, cnt) => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }, []);

  useEffect(() => {
    register(update);
  }, [register, update]);

  return (
    <div>
      {listItems.map(e => {
        return (
          <div key={e.id}>
            {e.title}
            {count[e.id]}
          </div>
        );
      })}
    </div>
  );
};

Помимо списка элементов для отображения (listItems) пусть этот компонент принимает еще и пропс-функцию register. С помощью этот функции меню будет сообщать в родительскую страницу, какую функцию нужно вызвать в тот момент, когда очередной виджет сообщит ей значение своего счетчика. В функцию register мы передадим функцию update - она занимается изменением стэйта. Сам стэйт так же переехал в компонент List.

Код страницы тогда приобретает следующие черты:

const Page = ({ children }) => {

  ...

  const handler = useRef();

  const register = useCallback(cb => {
    handler.current = cb;
  }, []);

  const onSetCount = useCallback((id, cnt) => {
    handler.current(id, cnt);
  }, []);

  return (
    <>
      <div>
        <List listItems={listItems} register={register} />
      </div>
      <div>
        {React.Children.map(children, c => {
          const clone = React.cloneElement(c, {
            onSetCount
          });
          return clone;
        })}
      </div>
    </>
  );
};

Страница передает в List пропс register.

<List listItems={listItems} register={register} />

Эта функция будет вызвана компонентом List и получит в качестве аргумента колбэк, который сохранит с использованием useRef:

const handler = useRef();

const register = useCallback(cb => {
  handler.current = cb;
}, []);

Когда очередной виджет сообщает странице значение своего счетчика, вызывается функция onSetCount:

const onSetCount = useCallback((id, cnt) => {
  handler.current(id, cnt);
}, []);

Она просто вызывает зарегистрированную List-ом колбэк-функцию. Ее вызов в свою очередь приводит к обновлению стэйта компонента List (благодаря вызову функции update).

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

"В react уже есть этот механизм" - скажите вы. И будете как всегда правы. Хук useImperativeHandle. Давайте посмотрим как мы можем с его помощью переписать наш последний вариант:

const List = React.forwardRef((props, ref) => {
  const [count, setCount] = useState({});

  const update = useCallback((id, cnt) => {
    setCount(cnts => ({ ...cnts, [id]: cnt }));
  }, []);

  useImperativeHandle(
    ref,
    () => {
      return {
        update
      };
    },
    [update]
  );

  return (
    <div>
      {props.listItems.map(e => {
        return (
          <div key={e.id}>
            {e.title}
            {count[e.id]}
          </div>
        );
      })}
    </div>
  );
});
const Page = ({ children }) => {

  ...

  const list = useRef();

  const onSetCount = useCallback((id, cnt) => {
    list.current?.update(id, cnt);
  }, []);

  return (
    <>
      <div>
        <List ref={list} listItems={listItems} />
      </div>
      <div>
        {React.Children.map(children, c => {
          const clone = React.cloneElement(c, {
            onSetCount
          });
          return clone;
        })}
      </div>
    </>
  );
};

Чуть попроще с одной стороны.

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

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

Спасибо, что дочитали.

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


  1. markelov69
    10.02.2025 19:42

    Как мы боролись с лишними рендерами в react

    С 2017 года начали использовать его в связке с MobX и эту проблему сразу как рукой сняло, по сей день. И не нужно заниматься этими извращениями превращающими код в месиво. React только для View. Управление состоянием, в том числе локальным компонента эта забота MobX'a, реакт до сих пор с ней отвратительно справляется.


    1. nin-jin
      10.02.2025 19:42

      Это каким таким волшебным образом mobx спасает вас от необходимости формировать и реконцилировать развесистый дом при добавлении нового элемента? А как он спасает вас от ререндеров из-за необходимости пересоздавать колбэки для обновления значений в них?


      1. clerik_r
        10.02.2025 19:42

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

        Откройте консоль и понажимайте:
        https://stackblitz.com/edit/stackblitz-starters-eowyumpg?file=src%2FApp.tsx

        Чего не скажешь о голом реакте
        https://stackblitz.com/edit/stackblitz-starters-6gcj5993?file=src%2FApp.tsx

        Ну и соответственно никакие zustand'ы от этого тоже не спасают, все лишнее перерендеривается только в путь
        https://stackblitz.com/edit/stackblitz-starters-zzn16f51?file=src%2FApp.tsx


        1. nin-jin
          10.02.2025 19:42

          На первый вопрос не ответил, а на второй прислал какую-то демку с состоянием в глобальной переменной и без пропсов.. вы кого пытаетесь обмануть?


          1. clerik_r
            10.02.2025 19:42

            На первый вопрос не ответил

            Добавление новых элементов к делу(лишние перерендеры компонентов, которые не должны перерендериваться) не относится.

             а на второй прислал какую-то демку с состоянием в глобальной переменной и без пропсов.. вы кого пытаетесь обмануть?

            1) Пропсы к делу не относятся, т.к. при изменении пропса компонент и так должен перерендериваться, в этом весь смысл.
            2) Там есть глобальный стейт и локальный. Тем более для React + MobX, нет разницы MobX стор подключен как глобальное состояние или локальное конкретного компонента.
            3) Вот пробросил метод через прос в дочерние компоненты, чтобы они его вызывали и меняли состояние у компонента родителя:
            https://stackblitz.com/edit/stackblitz-starters-k5jbjqqv?file=src%2FApp.tsx

            Разумеется ничего не поменялось и никаких лишних рендеров не добавилось.


            1. nin-jin
              10.02.2025 19:42

              То есть от проблем React, озвученных мной, MobX ни коем образом не спасает. ЧТД.


              1. dorzhevsky Автор
                10.02.2025 19:42

                100%.

                Вообще, наверное, странно, надеяться, что что-то, работающее поверх реакта, спасёт от проблем реакта.)


                1. clerik_r
                  10.02.2025 19:42

                  Эммм, ну вообще то MobX конкретно спасает от проблем реакта. Если вам всё ещё не очевидно как, с учётом того что я скидывал конкретные примеры кода, то у меня для вас плохие новости.


                1. nin-jin
                  10.02.2025 19:42

                  Ещё более странно использовать инструмент в котором столько нерешаемых проблем.


    1. Akhmed_theDark
      10.02.2025 19:42

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


      1. clerik_r
        10.02.2025 19:42

        зато есть просто и мощный zustand

        Он вообще даже близко не стоит с MobX'ом по удобству использования, и в zustand много boilerplate кода надо писать, поменьше чем в redux конечно, но все равно много по сравнению с MobX, где его нет вообще. А ещё zustand иммутабильный и это тоже минус.


      1. lear
        10.02.2025 19:42

        Я думаю за счет его простоты

        Напишите для сравнения с mobx код, который будет создавать локальный стэйт. Посмотрите как создаётся локальный стэйт на zustand и на mobx.


  1. KivApple
    10.02.2025 19:42

    Мне кажется, что это как раз тот случай, где нужно использовать redux, а лучше современный effector или там mobx. На худой конец накостылять свой глобальный стор на RxJS.

    Если вы внезапно осознаёте себя в ситуации, где вы жонглируете детьми, чтобы динамически совать в них callback'и, то стоит остановиться, сесть и подумать, а как вы оказались в такой ситуации и как из неё выбираться. В 99% случаев вы пишите неподдерживаемое говно, которое будет в половине случаев разваливаться, а в другой половине вызывать ререндер всей страницы.


    1. dorzhevsky Автор
      10.02.2025 19:42

      У нас в продукте используем redux. В данном случае все эти счётчики хранятся в слайсах каждого виджета. И цель была сообщить об этих счётчиках компоненту-меню.

      Лезть из компонента-меню в слайсы каждого виджета - только не это.

      Вынести эти счётчики в отдельный слайс - тоже как-то не очень, не понятно, кто владелец этого слайса, меню или виджеты.

      Так что иногда и при живом redux приходится немного подумать, как лучше организовать взаимодействие.


      1. clerik_r
        10.02.2025 19:42

        У нас в продукте используем redux

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

        А теперь пытаетесь бороться с проблемами, которые сами себе специально и сознательно создали. Спрашивается зачем их было создавать?


        1. dorzhevsky Автор
          10.02.2025 19:42

          Не до конца понял прямую связь redux и ререндеров. Можно без труда переписать код из статьи с использованием redux, и лишних ререндеров не будет вообще. Примерно так:

          const Page = ({ children }) => {
            const count = useSelector(state => ...);
          
            const listItems = useMemo(() => {
              return React.Children.map(children, c => {
                const { id, title } = c.props;
                return { id, title };
              });
            }, [children]);
          
            return (
              <>
                <div>
                  {listItems.map(e => {
                    return (
                      <div key={e.id}>
                        {e.title}
                        {count[e.id]}
                      </div>
                    );
                  })}
                </div>
                <div>{children}</div>
              </>
            );
          };

          Из виджета диспатчим экшн:

          const Widget = ({ id }) => { 
            ...
            const dispatch = useDispatch();
            useEffect(() => {
              dispatch({ type: "SET_COUNT", payload: { id, count: data.length } });
            }, [dispatch, id, data]);
            ...
          };

          Так что redux не всегда так плох.

          Но для решения нашей задачи redux не нужен, как и, возможно, не нужен какой-либо другой стейт менеджер.


          1. clerik_r
            10.02.2025 19:42

            Так что redux не всегда так плох.

            Вы просто посмотрите на код, который вы в комментарии своем привели. И ещё раз подумайте)

            как и, возможно, не нужен какой-либо другой стейт менеджер.

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

            Не до конца понял прямую связь redux и ререндеров

            Без костылей как у вас, она прямая, ибо иммутабильность и отсутствие умного и незаметного для разработчика React.memo


            1. sovaz1997
              10.02.2025 19:42

              А в чем именно проблема использования внутренних инструментов для состояния? Понятно, что у MobX свои преимущества есть. Но адекватно можно и на том, и на другом писать.


    1. bykostya
      10.02.2025 19:42

      Сложилось такое же впечатление...


    1. sovaz1997
      10.02.2025 19:42

      А я не думаю, что стейт-менеджер тут на что-то влияет.

      Что именно вы хотите решить, заменив useState на redux/MobX/ещё что-нибудь? Надо начать с первопричины.


      1. supercat1337
        10.02.2025 19:42

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

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

        Да, отсутствие такой оптимизации может замедлять веб-интерфейс.

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


        1. sovaz1997
          10.02.2025 19:42

          Именно! Я об этом ниже писал

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


          1. nin-jin
            10.02.2025 19:42

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


            1. sovaz1997
              10.02.2025 19:42

              Это да, всё от задачи зависит

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

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


  1. skeevy
    10.02.2025 19:42

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

    useImperativeHandle и половину извращений можно выбрасывать


    1. dorzhevsky Автор
      10.02.2025 19:42

      Все верно. В конце есть вариант с useImperativeHandle. Правда, useImperativeHandle само по себе ещё то извращение.


    1. sovaz1997
      10.02.2025 19:42

      Зачем вообще так делать, не лучше просто сверху данные передавать?

      Без всяких лишних useEffect-ов и useImperativeHandle

      Данные наверху, отображение внизу. Данные о количестве уже имеются наверху, и никаких пропсов не нужно.

      Если есть возможность написать без useEffect, значит нужно писать без useEffect, имхо.

      Ещё эти cloneElement, children.map. На ровном месте проблемы себе создают, а потом их решают. С архитектурой явно что-то не то.

      --------

      Ну и самое важное: можно же не отображать данные, которые не видны на экране (вы говорите, что там скролл есть).

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

      Это же самое простое, что можно сделать: просто не отображать, если не видно. Ну а данные вверху. Если данные рендерить не нужно, 90% проблем уходит сразу.


      1. dorzhevsky Автор
        10.02.2025 19:42

        Данные наверху, отображение внизу. Данные о количестве уже имеются наверху, и никаких пропсов не нужно.

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

        Или вы предлагается все данные централизовано загрузить где-то наверху?

        Ещё эти cloneElement, children.map. На ровном месте проблемы себе создают, а потом их решают. С архитектурой явно что-то не то.

        Ну мы не знаем сколько у нас виджетов же будет, у нас они постоянно добавляются и убираются, подстраиваясь под требования бизнеса. Удобно же, добавил виджет в качестве children, динамически получил пункт меню. Если у вас есть здравая идея на этот счет, делитесь.


        1. sovaz1997
          10.02.2025 19:42

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

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

          И как понять, постоянно убираются/добавляются. В общем, как и всегда, нужно больше информации, чтобы можно было подумать над решением)