Why Did You Render?
I spent too long looking at React Profiler and tweaking rendering performance today. Here are a few tricks I learned.
Huge disclaimer. If you’re new to React or programming in general, you don’t need to worry about performance. Read “When to useMemo and useCallback” on Kent C. Dodds’s blog.
I have already worked for a year on the app I was optimizing today. A year of my fulltime job! And I wasn’t the only developer. It has both keyframes and react‑spring animations, an API client codegenned from Protobuf schema, and obeying Zawinski’s law, it also reads and sends email.
It isn’t too soon to think about performance anymore, because it affects user experience on weaker machines. I just had to make this point. Let’s go to the main part.
Finding the culprit 🔎
We throttled our CPU and found the part of the app which feels wrong. But how do we find the slow components? How can we know what to optimize?
The official React Profiler will answer these questions.
It may be, however, laconic in its answers, when the hooks cause the update. The issue requesting this is open since June 2019.
DevTools: Profiler: Show which hooks changed #16477
The Tools of Deduction 🔬
So we know which component is causing trouble, but we need more details to discover the cause. Wrapping everything in useMemo is a dumb idea. We call ourselves engineers; let’s pretend to be serious.
We download the results of our profiling, call them “before.json” or something like that, and snap a few screenshots for convenience. We’ll need them for comparison later, to make sure our changes are truly improvements.
Monkey patching React 🐒
@welldone-software/why-did-you-render
monkey patches React to detect avoidable
re-renders. Tools for debugging are the only good use case for monkey patching.
I added this beautiful piece of code to my entry point.
tsx
if (process.env.NODE_ENV === "development") {document.title += " 🌴";const DEBUG_PERF = false;if (DEBUG_PERF) {// @ts-ignore for CI; why-did-you-render is in dev depstype WhyDidYouRender =typeof import("@welldone-software/why-did-you-render").default;// eslint-disable-next-line global-require, import/no-extraneous-dependenciesconst whyDidYouRender =require("@welldone-software/why-did-you-render") as WhyDidYouRender;whyDidYouRender(React, {trackAllPureComponents: true,collapseGroups: true,trackExtraHooks: [[// eslint-disable-next-line global-requirerequire("./data/store").__internal_import_me_only_for_debugging__store,"useSelectors",],],});}}
tsx
if (process.env.NODE_ENV === "development") {document.title += " 🌴";const DEBUG_PERF = false;if (DEBUG_PERF) {// @ts-ignore for CI; why-did-you-render is in dev depstype WhyDidYouRender =typeof import("@welldone-software/why-did-you-render").default;// eslint-disable-next-line global-require, import/no-extraneous-dependenciesconst whyDidYouRender =require("@welldone-software/why-did-you-render") as WhyDidYouRender;whyDidYouRender(React, {trackAllPureComponents: true,collapseGroups: true,trackExtraHooks: [[// eslint-disable-next-line global-requirerequire("./data/store").__internal_import_me_only_for_debugging__store,"useSelectors",],],});}}
Honestly, it didn’t help me that much because I already profiled the app and had a fairly good idea what’s wrong. I just didn’t know the exact reason.
useWhyDidYouUpdate 🎣
Based on a recipe I found at
usehooks.com, I’ve added a new hook
to my codebase. Sprinkled it with some TypeScript, changed console.log
for
console.group
and console.table
, et voilà!
tsx
/* eslint-disable no-console */import { useRef, useEffect } from "react";/*** Based on https://usehooks.com/useWhyDidYouUpdate/** @param name a key to identify console group* @param props object to persist and compare*/export function useWhyDidYouUpdate(name: string,props: Record<string, unknown>) {const previousProps = useRef<Record<string, unknown>>();useEffect(() => {const { current } = previousProps;if (current) {const allKeys = Object.keys({ ...current, ...props });const changesObj: Record<string, unknown> = {};allKeys.forEach((key) => {if (current[key] !== props[key]) {changesObj[key] = {from: current[key],to: props[key],};}});console.group(`%c[why-did-you-update] %c${name}`,"font-weight: bold;","color: dodgerblue; font-weight: bold;");if (Object.keys(changesObj).length) {console.table(changesObj);} else {console.log("Something else changed.");}console.groupEnd();}previousProps.current = props;});}
tsx
/* eslint-disable no-console */import { useRef, useEffect } from "react";/*** Based on https://usehooks.com/useWhyDidYouUpdate/** @param name a key to identify console group* @param props object to persist and compare*/export function useWhyDidYouUpdate(name: string,props: Record<string, unknown>) {const previousProps = useRef<Record<string, unknown>>();useEffect(() => {const { current } = previousProps;if (current) {const allKeys = Object.keys({ ...current, ...props });const changesObj: Record<string, unknown> = {};allKeys.forEach((key) => {if (current[key] !== props[key]) {changesObj[key] = {from: current[key],to: props[key],};}});console.group(`%c[why-did-you-update] %c${name}`,"font-weight: bold;","color: dodgerblue; font-weight: bold;");if (Object.keys(changesObj).length) {console.table(changesObj);} else {console.log("Something else changed.");}console.groupEnd();}previousProps.current = props;});}
I added it to no-restricted-syntax
ESLint rule, to make sure I
don’t deploy it staging by accident.
And here’s how it looks like in browser devtools.
That’s all! How do you investigate performance concerns in your apps? Did I miss something? Feel free to reach out and let me know!