React Rendering, Explained: Re-renders, Memoisation and When Not to Optimise | Roman Humagain

React Rendering, Explained: Re-renders, Memoisation and When Not to Optimise | Roman Humagain

How React rendering really works: what triggers re-renders, when React.memo, useMemo and useCallback help, common context pitfalls, and how to profile before optimising.

There is a particular kind of React codebase where every component is wrapped in React.memo, every function is a useCallback, every object is a useMemo — and it is no faster than the plain version. Usually it is slower, and always it is harder to read. That happens when memoisation is applied as a habit rather than as a fix for a measured problem. So: what actually causes a re-render, and when does memoisation genuinely help? What triggers a render A component re-renders when one of three things happens: Its own state changes A context it consumes changes Its parent re-renders The third is the one that surprises people. When a parent re-renders, React re-renders its entire subtree by default — regardless of whether any prop changed. Props are not a trigger; they are only checked if you have opted into a comparison with React.memo. Rendering is not the same as touching the DOM This is the distinction that dissolves most anxiety about re-renders. "Rendering" means React calls your component function and produces a description of the UI. It then compares that description to the previous one and updates only the DOM nodes that actually differ. Calling a function that returns some objects is cheap. Writing to the DOM is expensive. A component re-rendering a hundred times while producing identical output does very little work — React reconciles and writes nothing. So "this component re-renders too often" is not by itself a problem. The problem is when a re-render is expensive : it runs heavy computation, it renders a large subtree, or it happens on a high-frequency event like scroll or pointer move. What memoisation actually does React.memo makes a component skip re-rendering when its props are shallowly equal to last time. The catch is in "shallowly": // This memo does nothing at all const Child = React.memo(({ config }) => ...); function Parent() { return <Child config={{ theme: "dark" }} />; // new object every render } The object literal is a new reference on every render, so the shallow comparison always fails. You have added a comparison cost and removed no work. The same applies to inline functions and arrays. This is where useMemo and useCallback come in — not as speed-ups in themselves, but as tools for keeping a reference stable so that a memoised child, or a dependency array, behaves as intended. const config = useMemo(() => ({ theme }), [theme]); const onSelect = useCallback((id) => setSelected(id), []); They are only useful in service of something else: a memoised child, a useEffect dependency, or a genuinely costly computation. Wrapping a cheap arithmetic expression in useMemo costs more than it saves — you have added a dependency array to allocate and compare in order to avoid a multiplication. The context trap Context is the most common source of real, unnecessary re-rendering, because every consumer re-renders when the value changes — even a consumer that only reads one unchanged field. And the value changes more often than people expect: // New object identity on every provider render <AppContext.Provider value={{ user, theme, setTheme }}> Two fixes, in order of preference. Split contexts by update frequency, so rarely-changing data does not ride along with rapidly-changing data: <UserContext.Provider value={user}> <ThemeContext.Provider value={themeValue}> Or, if the shape must stay together, memoise the value: const value = useMemo(() => ({ user, theme, setTheme }), [user, theme]); Keys and lists Using an array index as a key is fine for a static list and a bug waiting to happen for anything that reorders, filters or deletes. React uses keys to decide which element is which; index keys tell it that the item in position two is the same item it was before, which is exactly wrong after a deletion. The symptom is state attached to the wrong row — a checked checkbox following the wrong item. Use a stable id from your data. If items genuinely have no id, that is usually a hint the data layer should provide one. Measure before you change anything The React DevTools Profiler answers the question directly. Record an interaction, and it shows which components rendered, how long each took, and why it rendered. What to look for: Components taking more than a few milliseconds to render Large subtrees re-rendering on an unrelated state change Anything rendering on every frame of a scroll or drag If the profiler shows your entire tree rendering in two milliseconds, there is no performance problem to solve, and memoising it will make the code worse for no gain. When optimisation is genuinely warranted Long lists. Hundreds of rows want virtualisation, not memoisation — render only what is visible. Expensive computation. Sorting or filtering thousands of items on every render is a real useMemo. High-frequency updates. Scroll, pointer move and animation want to bypass React state entirely — a ref plus a direct style write avoids a render per frame. Genuinely heavy subtrees. A chart or a canvas that re-mounts unnecessarily is worth a React.memo with a deliberate comparison. The rule I follow Write it plainly first. Profile when something feels slow. Optimise the specific thing the profiler points at, and leave the rest alone. Premature memoisation has a real cost that rarely gets counted: every useMemo is a dependency array that can go stale, and a reader who now has to work out whether it matters. Correct, readable code that is fast enough beats defensively optimised code every time.

Continue to Roman Humagain