Unmasking UI Glitches in React: How useMemo Saved the Day

While working on a project, I encountered a peculiar behavior that initially stumped me. I was aiming to improve Cumulative Layout Shift (CLS) by introducing a skeleton loader. However, after implementing this loader, I noticed the User Interface (UI) was occasionally jumpy, leading to a surge in CLS.

Upon reviewing screen recordings, I discovered the issue: the skeleton loader would disappear for a few milliseconds, causing the content to shift upward. Immediately after, a banner would render and push the content back down. The effect was more noticeable with CPU slowdown enabled.

The code seemed fine, so I was unsure about the root cause. The implementation wasn't optimal, but there was no clear reason for this behavior given the simplicity of the output variable.

Here's the code I was dealing with:

const { settings, onAction: handleAction } = useSettings();

const showSettingsBanner = settings.data?.blockSupported && settings.data?.isAppRemoved;

const tutorial = useGetTutorialQuery();
const tutorialDismissed = tutorial.data?.appTutorial?.dismissed;

const {
  data: defaultData,
  error: defaultDataError,
  isSuccess: defaultDataIsSucess,
  isLoading: defaultDataIsLoading
} = useIsDefaultQuery();

const showPermissionsBanner = (isApp1() || isApp2()) && !defaultDataIsLoading && !defaultDataError && defaultData && !defaultData.isDefault && offerExists;

const showTutorialBanner = ((isApp1() || isApp2()) && tutorialDismissed === true) || tutorialDismissed === undefined ? false : true;

const showFreeBanner = isApp2() && someBillingConditions

let bannerToDisplay = (() => {
  if (
    (!settings.isSuccess && !settings.isError) ||
    (!tutorial.isSuccess && !tutorial.isError) ||
    (!defaultDataIsSucess && !defaultDataError)
  ) {
    return 'skeleton';
  }
  if (showFreeBanner) {
    return 'criticalFreeBanner';
  } else if (showSettingsBanner) {
    return 'criticalSettingsBanner';
  } else if (showPermissionsBanner) {
    return getWarningBanner(showPermissionsBanner);
  } else if (showTutorialBanner) {
    return getTutorialBanner(showTutorialBanner);
  }
})();

I began to suspect that the issue might be beyond my grasp, but I decided to try a performance optimization technique using React's useMemo hook—and it worked!

const { settings, onAction: handleAction } = useSettings();
const tutorial = useGetTutorialQuery();
const {
  data: defaultData,
  error: defaultDataError,
  isSuccess: defaultDataIsSucess,
} = useIsDefaultQuery();

const bannerToDisplay = useMemo(() => {
  const showSettingsBanner = settings.data?.blockSupported && settings.data?.isAppRemoved;

  const tutorialDismissed = tutorial.data?.appTutorial?.dismissed;

  const showPermissionsBanner = (isApp1() || isApp2()) && !defaultDataIsLoading && !defaultDataError && defaultData && !defaultData.isDefault && offerExists;

  const showTutorialBanner = ((isApp1() || isApp2()) && tutorialDismissed === true) || tutorialDismissed === undefined ? false : true;

  const showFreeBanner = isApp2() && someBillingConditions

  if (
    (!settings.isSuccess && !settings.isError) ||
    (!tutorial.isSuccess && !tutorial.isError) ||
    (!defaultDataIsSucess && !defaultDataError)
  ) {
    return 'skeleton';
  }
  if (showFreeBanner) {
    return 'criticalFreeBanner';
  } else if (showSettingsBanner) {
    return 'criticalSettingsBanner';
  } else if (showPermissionsBanner) {
    return getWarningBanner(showPermissionsBanner);
  } else if (showTutorialBanner) {
    return getTutorialBanner(showTutorialBanner);
  }
})

This surprising issue sparked my curiosity about how heavy computations and the way React handles rendering can impact performance and UI stability, especially under heavy load or on slower CPUs.

React function components execute from top to bottom on each render. Thus, if you perform heavy computations directly in the render function, these computations will be re-run each time the component re-renders. Although React employs a mechanism called Virtual DOM to optimize this process, running these computations repeatedly can slow down the rendering process.

One way to optimize heavy computations is by using React's built-in hooks, useMemo and useCallback. These hooks memoize computations and functions, preventing unnecessary recalculations when their dependencies have not changed.

useMemo is used to remember the result of a function, re-computing the memoized value only when one of its dependencies changes. It can prevent the need to re-run an expensive computation on each render, resulting in performance improvements.

However, it's crucial to understand that overusing memoization can introduce unnecessary complexity into your codebase and sometimes lead to more overhead than benefits. So, it's essential to identify any actual performance bottlenecks before optimizing.

Another aspect that could lead to UI glitches is layout thrashing, where repeated read/write operations to the DOM force the browser to perform multiple layout calculations before a page is visually updated. In my case, alternating between showing the skeleton loader and rendering the banner might have caused the browser to recalculate the layout more frequently than necessary, leading to the jumpy UI.

React mitigates some of these effects by batching updates to the Virtual DOM. Still, direct DOM manipulations or frequent layout changes in components can sometimes cause layout thrashing and degrade performance.

Understanding these behaviors allows us to write more efficient and performant React applications, ensuring a smoother user experience even under heavy loads or on slower CPUs. As React developers, we need to be mindful of potential pitfalls and take advantage of the tools and techniques React provides to optimize performance.