React Compiler and Custom Hooks: When Memoization Works and When It Doesn't

A recap: what the compiler can memoize

The previous articles showed the compiler caching values and callbacks inside components — eliminating useMemo and useCallback. But the official React docs add one more item to the list:

"React Compiler only memoizes React components and hooks, not every function."

The compiler memoizes hooks too. But what exactly counts as a hook?


What makes a function a "hook" to the compiler

The compiler uses two conditions. Both must be true:

  1. The function name starts with use (following React's naming convention)
  2. The function actually calls at least one React hookuseState, useRef, useEffect, useCallback, useMemo, etc.

A function named useFormatName that only does string manipulation and never calls any React hook? The compiler treats it as a plain utility function. No cache is added.


Demo 3: the "fake" hook that gets skipped

Try it live in the demo app — open Demo 3 and click "Force re-render" while watching the Live Log panel.

Consider these two custom hooks side by side:

// ── Hook A: WITHOUT useCallback ─────────────────
export function useFormatName() {
  const format = (first: string, last: string) => {
    return `${last.toUpperCase()}, ${first}`
  }
  return format
}

// ── Hook B: WITH useCallback ────────────────────
export function useFormatNameMemo() {
  const format = useCallback((first: string, last: string) => {
    return `${last.toUpperCase()}, ${first}`
  }, [])
  return format
}

Both are named use*. Both return a formatter function. The difference:

  • Hook A calls zero React hooks — it's just a function that returns a function
  • Hook B calls useCallback — a real React hook

What happens when the component re-renders

A component uses both hooks and tracks their references:

function Demo3_WithMemo() {
  const [counter, setCounter] = useState(0)

  const formatA = useFormatName()
  const formatB = useFormatNameMemo()

  useRefCheck('Demo3.formatA (no useCallback)', formatA)
  useRefCheck('Demo3.formatB (with useCallback)', formatB)

  return (
    <div>
      <Control
        counter={counter}
        onForceRender={() => setCounter((c) => c + 1)}
      />
      <NameDisplay label="A" formatter={formatA} variant="a" />
      <NameDisplay label="B" formatter={formatB} variant="b" />
    </div>
  )
}

Click "Force re-render" and the log shows:

[Demo3.formatA (no useCallback)] ❌ NEW ref     ← compiler skipped it
[Demo3.formatB (with useCallback)] ✅ same ref  ← useCallback preserved it

Hook A creates a new function every render. The compiler didn't add any caching because it saw a use* function that calls zero hooks — so it treated it as a regular function.

Hook B stays stable because useCallback (a React API) preserves the reference regardless of the compiler.

The child flashes

The NameDisplay component includes a flash indicator — it briefly shows a yellow border and "⚡ re-rendered" when it actually re-renders. Side A (the fake hook) flashes on every click. Side B (with useCallback) stays calm.


Demo 4: the "real" hook that gets memoized

The counterpart to Demo 3 — open it in the demo app and run the same experiment.

Now consider the opposite case. Hook A is rewritten to call useRef:

// ── Hook A: a REAL custom hook (calls useRef) ───
export function useFormatNameReal() {
  const separator = useRef(', ')

  const format = (first: string, last: string) => {
    return `${last.toUpperCase()}${separator.current}${first}`
  }
  return format
}

// ── Hook B: NOT a real hook (calls zero React hooks) ────
export function useFormatNameFake() {
  const format = (first: string, last: string) => {
    return `${last.toUpperCase()}, ${first}`
  }
  return format
}

The only difference from Demo 3: Hook A now calls useRef(). The useRef call doesn't change the formatting logic — the separator was already ", ". But it tells the compiler: this is a real hook.

What happens when the component re-renders

[Demo4.formatA (real hook)] ✅ same ref   ← compiler cached it!
[Demo4.formatB (fake hook)] ❌ NEW ref    ← compiler skipped it

The roles are reversed. Hook A is stable — the compiler added its cache because it recognised the hook. Hook B creates a new function every time — the compiler saw no React hooks inside and left it alone.

Side A does not flash. Side B flashes on every click.


Why "use no memo" doesn't break Hook A

Both demos include a "use no memo" toggle. The directive disables the compiler's cache for the component it's placed in:

export default function Demo4_NoMemo() {
  'use no memo'
  // …
}

But the hooks live in separate files. useFormatNameReal() is compiled independently — the directive in the component doesn't reach it. So even with "use no memo" active on the component, Hook A still produces a stable reference:

[Demo4.formatA (real hook)] ✅ same ref   ← hook is still memoized
[Demo4.formatB (fake hook)] ❌ NEW ref    ← compiler skipped it (as before)

This confirms that the compiler's hook memoization is independent of the component's memoization. Each file is compiled on its own.


What the compiler sees internally

When the compiler processes a use* function, it asks: does this function call any React hooks?

FunctionCalls React hooks?Compiler action
useFormatName() — Demo 3 Hook ANoSkipped — treated as plain function
useFormatNameMemo() — Demo 3 Hook BYes (useCallback)Memoized by useCallback (React API, not compiler cache)
useFormatNameReal() — Demo 4 Hook AYes (useRef)Memoized — compiler adds _c() cache
useFormatNameFake() — Demo 4 Hook BNoSkipped — treated as plain function

The key insight: useCallback in Demo 3 Hook B works not because the compiler memoized it, but because useCallback is a React API that works anywhere. The compiler's own caching only kicks in when it recognises the function as a hook (Demo 4 Hook A).


Does this matter in practice?

Almost never. Real custom hooks virtually always call useState, useEffect, useRef, or other React hooks — that's what makes them hooks. The compiler handles them automatically.

The Demo 3 scenario (a use* function that calls no hooks) is a naming mistake, not a real hook. The Rules of Hooks require that hooks call other hooks or are called from components. A function named useFormatName that does pure string manipulation should just be named formatName — drop the use prefix.

If you do have a genuine utility function that needs a stable reference (perhaps it's passed as a prop to a child), you have two options:

  1. Rename it — drop the use prefix and define it outside the component, so it's created once
  2. Wrap it in useCallback — this works with or without the compiler

Summary

RuleWhat happens
ComponentAlways memoized by the compiler
use* function that calls React hooksMemoized by the compiler (a real hook)
use* function that calls NO hooksSkipped by the compiler (just a function)
useCallback / useMemoAlways works — it's a React API, not a compiler feature

The compiler respects React's own definition of a hook. If a function follows the naming convention and actually behaves like a hook (by calling other hooks), the compiler memoizes it. Everything else is left alone.