React Compiler Testing: How React Forget Changes memo and useCallback Strategies

React Compiler Testing: How React Forget Changes memo and useCallback Strategies

React Compiler — previously called React Forget — automatically memoizes your components and hooks. It eliminates the need for manual React.memo(), useCallback, and useMemo in most cases. This is a significant shift: tests that were written to verify memoization behaviour may now be testing something the compiler handles, or testing assumptions that no longer apply.

This guide covers what changes with React Compiler enabled, how to adjust your test strategy, and what still needs explicit testing.

What React Compiler Does

React Compiler analyzes your component code at build time and inserts memoization automatically. A component that looked like this:

// Before React Compiler
function ProductCard({ product, onAddToCart }: Props) {
  return (
    <div>
      <h2>{product.name}</h2>
      <p>{product.price}</p>
      <button onClick={onAddToCart}>Add to cart</button>
    </div>
  )
}

// You had to wrap it manually
export const MemoizedProductCard = React.memo(ProductCard)

With React Compiler enabled, the compiler handles memoization at the call site — React.memo becomes unnecessary. The compiler tracks which props and state a component actually uses and only re-renders when those values change.

What This Means for Tests

1. Don't Test That memo() Prevents Re-renders

A common (and fragile) test pattern counted render calls to verify React.memo was working:

// ❌ Don't write tests like this
it('does not re-render when unrelated props change', () => {
  const renderCount = vi.fn()

  function TrackedProductCard(props: Props) {
    renderCount()
    return <ProductCard {...props} />
  }

  const { rerender } = render(
    <TrackedProductCard product={product} onAddToCart={vi.fn()} />
  )

  expect(renderCount).toHaveBeenCalledTimes(1)

  // Pass new function reference — this used to cause re-render without memo
  rerender(<TrackedProductCard product={product} onAddToCart={vi.fn()} />)

  // This assertion is now meaningless — compiler handles it
  expect(renderCount).toHaveBeenCalledTimes(1)
})

This test is testing the compiler's behaviour, not your code's correctness. React Compiler makes this pass or fail based on its own analysis, not on your use of React.memo.

Write tests that verify visible behaviour instead:

// ✅ Test what matters: the UI is correct
it('shows product name and price', () => {
  const product = { id: '1', name: 'Wireless Headphones', price: 79.99 }

  render(<ProductCard product={product} onAddToCart={vi.fn()} />)

  expect(screen.getByText('Wireless Headphones')).toBeInTheDocument()
  expect(screen.getByText('79.99')).toBeInTheDocument()
})

it('calls onAddToCart when button is clicked', async () => {
  const onAddToCart = vi.fn()
  const product = { id: '1', name: 'Headphones', price: 79.99 }

  render(<ProductCard product={product} onAddToCart={onAddToCart} />)

  await userEvent.click(screen.getByRole('button', { name: 'Add to cart' }))

  expect(onAddToCart).toHaveBeenCalledOnce()
})

2. Remove Tests That Verify useCallback Identity

Tests that asserted callback identity across renders were testing JavaScript reference equality, not business logic:

// ❌ This pattern is now meaningless
it('handleSubmit reference is stable across re-renders', () => {
  const { result, rerender } = renderHook(() => {
    const [count, setCount] = useState(0)
    const handleSubmit = useCallback(() => setCount(c => c + 1), [])
    return { count, handleSubmit }
  })

  const firstRef = result.current.handleSubmit
  rerender()
  // This may or may not be the same reference with React Compiler
  expect(result.current.handleSubmit).toBe(firstRef)
})

The compiler may or may not produce the same reference — that detail is an implementation choice. Test the effect instead:

// ✅ Test what the callback does
it('increments count when handleSubmit is called', async () => {
  function CounterForm() {
    const [count, setCount] = useState(0)
    const handleSubmit = useCallback(() => setCount(c => c + 1), [])
    return (
      <>
        <p>Count: {count}</p>
        <button onClick={handleSubmit}>Increment</button>
      </>
    )
  }

  render(<CounterForm />)

  expect(screen.getByText('Count: 0')).toBeInTheDocument()

  await userEvent.click(screen.getByRole('button', { name: 'Increment' }))

  expect(screen.getByText('Count: 1')).toBeInTheDocument()
})

3. useMemo Tests Should Assert the Value, Not the Cache

Tests that relied on counting how many times an expensive computation ran are testing infrastructure, not correctness:

// ❌ Brittle — tests internal memoization
it('computeFilteredList is only called once for the same input', () => {
  const computeSpy = vi.spyOn(filterUtils, 'computeFilteredList')

  const { rerender } = render(<FilteredList items={items} filter="active" />)
  rerender(<FilteredList items={items} filter="active" />)

  expect(computeSpy).toHaveBeenCalledTimes(1)
})
// ✅ Test the correct output
it('shows only active items when filter is "active"', () => {
  const items = [
    { id: '1', name: 'Active Task', status: 'active' },
    { id: '2', name: 'Done Task', status: 'done' },
  ]

  render(<FilteredList items={items} filter="active" />)

  expect(screen.getByText('Active Task')).toBeInTheDocument()
  expect(screen.queryByText('Done Task')).not.toBeInTheDocument()
})

What Still Needs Explicit Testing

React Compiler doesn't change everything. These patterns still require tests:

Derived State Logic

Transformations applied to data before rendering:

function ProductPrice({ price, discount }: { price: number; discount: number }) {
  const finalPrice = price * (1 - discount / 100)
  const savings = price - finalPrice

  return (
    <div>
      <span className="price-original">{price.toFixed(2)}</span>
      <span className="price-final">{finalPrice.toFixed(2)}</span>
      <span className="price-savings">Save {savings.toFixed(2)}</span>
    </div>
  )
}
describe('ProductPrice', () => {
  it('calculates discounted price correctly', () => {
    render(<ProductPrice price={100} discount={20} />)

    expect(screen.getByText('80.00')).toBeInTheDocument()
    expect(screen.getByText('Save 20.00')).toBeInTheDocument()
  })

  it('shows original price with no discount', () => {
    render(<ProductPrice price={50} discount={0} />)

    expect(screen.getByText('50.00')).toBeInTheDocument()
    expect(screen.getByText('Save 0.00')).toBeInTheDocument()
  })
})

Conditional Rendering Logic

function NotificationBadge({ count }: { count: number }) {
  if (count === 0) return null
  if (count > 99) return <span className="badge">99+</span>
  return <span className="badge">{count}</span>
}
describe('NotificationBadge', () => {
  it('renders nothing when count is 0', () => {
    const { container } = render(<NotificationBadge count={0} />)
    expect(container).toBeEmptyDOMElement()
  })

  it('renders exact count for small numbers', () => {
    render(<NotificationBadge count={7} />)
    expect(screen.getByText('7')).toBeInTheDocument()
  })

  it('caps display at 99+ for large counts', () => {
    render(<NotificationBadge count={150} />)
    expect(screen.getByText('99+')).toBeInTheDocument()
  })
})

Side Effects in useEffect

React Compiler doesn't auto-manage effect dependencies or timing:

function AnalyticsTracker({ pageId }: { pageId: string }) {
  useEffect(() => {
    analytics.track('page_view', { pageId })
  }, [pageId])

  return null
}
vi.mock('@/lib/analytics', () => ({
  analytics: {
    track: vi.fn(),
  },
}))

import { analytics } from '@/lib/analytics'

describe('AnalyticsTracker', () => {
  it('tracks page view on mount', () => {
    render(<AnalyticsTracker pageId="dashboard" />)

    expect(analytics.track).toHaveBeenCalledWith('page_view', { pageId: 'dashboard' })
  })

  it('tracks new page view when pageId changes', () => {
    const { rerender } = render(<AnalyticsTracker pageId="dashboard" />)

    rerender(<AnalyticsTracker pageId="settings" />)

    expect(analytics.track).toHaveBeenCalledWith('page_view', { pageId: 'settings' })
    expect(analytics.track).toHaveBeenCalledTimes(2)
  })
})

Checking for React Compiler Compatibility

The React compiler team provides an eslint plugin that flags patterns the compiler can't optimize:

npm install -D eslint-plugin-react-compiler

.eslintrc.js:

module.exports = {
  plugins: ['react-compiler'],
  rules: {
    'react-compiler/react-compiler': 'error',
  },
}

Add this to your CI pipeline alongside tests. A component that the compiler can't optimize will throw an ESLint error — this is how you know a component will fall back to default React rendering behaviour.

Enabling React Compiler Gradually

React Compiler supports incremental adoption with the compilationMode option:

// next.config.js
module.exports = {
  experimental: {
    reactCompiler: {
      compilationMode: 'annotation', // Only compile files with 'use memo' directive
    },
  },
}

With annotation mode, only files that opt in are compiled. This lets you test the compiler's effect on specific components before applying it globally.

Running the Test Suite

# Run all tests
npx vitest run

<span class="hljs-comment"># Run with coverage to find gaps
npx vitest run --coverage

<span class="hljs-comment"># Run ESLint + tests together
npm run lint && npx vitest run

What Automated Tests Miss

Even with React Compiler enabled and solid unit tests, there are gaps:

  • Compiler version regressions — a React Compiler update may change which components get optimized
  • Large list performance — compiler memoization may not cover all rerender hotspots under real data volumes
  • Interaction timing — optimistic updates and animations may behave differently with compiler-generated memoization

For production coverage, HelpMeTest runs automated browser tests against your deployed app. When React Compiler's output produces unexpected UI behaviour in a real browser session, scheduled tests catch it.

Summary

React Compiler changes what's worth testing:

  • Remove tests that count re-renders, verify callback reference stability, or assert memo cache hits
  • Keep tests that verify visible output, correct calculations, conditional rendering, and side effect timing
  • Add ESLint React Compiler plugin to catch incompatible patterns in CI

The shift is from testing infrastructure (memoization mechanics) to testing behaviour (what users see and experience). React Compiler handles the optimization layer — your job is to verify that what renders is correct.

Read more