Hirefullstack – Software Engineering & IT-Beratung aus Berlin
← Back to overview
Advanced 13 min read

Pitfalls: structure & performance

Why memo often does not help – and what does

In one sentence

Most speed problems in React are solved by restructuring, not by memoising. And most “problems” are not problems at all.

Before you optimise anything: the vast majority of React apps are fast enough. And when they are not, the cause is surprisingly rarely the drawing.

Careful Measure first

Open the ProfilerA measuring tool in the React DevTools that shows which component took how long. in the React DevTools and record the interaction. It tells you which component took how long and why it redrew at all. Without measuring you are optimising guesses.

1. memo does nothing when the props are always new

Not like this
const Child = memo(ItemList);

<Child
  items={data.filter(Boolean)}      // a new array
  style={{ padding: 8 }}            // a new object
  onClick={() => pick(id)}          // a new function
/>
Do this instead
const style = { padding: 8 };       // outside the component

const visible = useMemo(() => data.filter(Boolean), [data]);
const handleClick = useCallback(() => pick(id), [id]);

<Child items={visible} style={style} onClick={handleClick} />

memo compares props on the surface. One unstable prop is enough for the comparison to fail every time – and then the optimisation only costs time.

2. The children trick

Decoupling an expensive subtree from a counter
HeavyTree redraws on every click
function Page() {
  const [n, setN] = useState(0);
  return (
    <div>
      <button onClick={() => setN(n + 1)}>{n}</button>
      <HeavyTree />
    </div>
  );
}
HeavyTree is left alone
function Page({ children }) {
  const [n, setN] = useState(0);
  return (
    <div>
      <button onClick={() => setN(n + 1)}>{n}</button>
      {children}
    </div>
  );
}

// Parent:
<Page><HeavyTree /></Page>

children is created in the parent. When Page redraws, that children element is the same object as before – so React skips the subtree entirely, without any memo.

3. State as deep as possible

colocation.tsx
// ❌ the search text lives in App -> every key press redraws everything
function App() {
  const [q, setQ] = useState("");
  return (
    <>
      <Header />
      <Search q={q} setQ={setQ} />
      <HugeList />
      <Footer />
    </>
  );
}

// ✅ the text lives where it is used
function Search() {
  const [q, setQ] = useState("");
  return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}

4. Context as a redraw cannon

context.tsx
// One context holding everything -> any change redraws every consumer
<AppContext value={{ user, theme, cart, notifications }}>

// Better: split by how often things change
<UserContext value={user}>
  <ThemeContext value={theme}>
    <CartContext value={cart}>
React.memo does not protect against context updates – the value goes around the props.

5. Large lists

Past a few hundred visible rows no amount of memo helps – at that point the page itself is the problem. The answer is VirtualisationFor very long lists, only actually rendering the rows that are currently visible. (@tanstack/react-virtual, react-window): only the visible rows actually exist.

6. What is actually slow

priorities.txt
Common real causes – roughly in this order:

1. JavaScript bundles that are too big  -> code splitting, dynamic imports
2. Images with no size or modern format -> next/image, modern formats
3. Waterfall requests                    -> load in parallel, fetch on the server
4. Huge lists in the page                -> virtualise
5. Expensive computations                -> useMemo, web workers

Rarely the cause:
• "the component draws twice"
• a missing memo wrapper
Tip The order of the tools
  1. Change the structure (move state, use children, split things up). 2. Prioritise drawing (useTransition). 3. Only then memoise. 4. And sooner or later the React Compiler takes that over anyway.

Does it stick?

5 questions on this lesson. Wrong answers show up in your stats.

Now write it yourself

Decouple the expensive subtree

HeavyTree counts how often it was drawn. Right now it redraws on every click of the counter. Restructure things so the counter no longer touches the subtree – without using memo.

  • The counter still works
  • After three clicks HeavyTree is still at one draw
  • No React.memo is used
import { useRef, useState } from "react";

function HeavyTree() {
  const renders = useRef(0);
  renders.current += 1;
  return <p data-testid="heavy">Renders: {renders.current}</p>;
}

export default function App() {
  const [n, setN] = useState(0);

  // TODO: restructure so HeavyTree is untouched by the counter
  return (
    <div style={{ fontFamily: "system-ui", padding: 16 }}>
      <button onClick={() => setN((v) => v + 1)}>Counter: {n}</button>
      <HeavyTree />
    </div>
  );
}

Hirefullstack

Need React firepower on your team?

We have been building React and Next.js applications for clients across Germany for years – as a single expert, as reinforcement for an existing team, or as a complete Scrum team.

Talk about your project →