Initializing Enclave...

How to Fix the useReducer Dispatch Infinite Loop in React (Render Phase Dispatch)

Threat/Impact Level: HIGH | Downtime Risk: HIGH | Time to Fix: 5–15 mins


TL;DR

  • What broke: A dispatch call from useReducer is executing directly in the component render body, causing React to re-render → dispatch → re-render infinitely until the browser tab crashes or becomes unresponsive.
  • How to fix it: Move every dispatch call out of the render phase and into a useEffect hook (with a correct dependency array), an event handler, or an async callback.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your component and get the corrected code without sending your source to a third-party server.

The Incident (What Does the Error Mean?)

You will typically see one or more of the following in the browser console:

Warning: Cannot update a component (`App`) while rendering a different component (`ChildComponent`).

Uncaught Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.
    at renderWithHooks (react-dom.development.js:14985)
    at updateFunctionComponent (react-dom.development.js:17356)
    at ...

The immediate consequence is a fully frozen or crashed UI. React's internal fiber reconciler detects the runaway render count (default limit: ~25 renders) and hard-throws, unmounting the component tree. On low-end devices or under production load, the browser tab may become completely unresponsive before React's guard kicks in.


The Attack Vector / Blast Radius

This is a self-inflicted denial-of-service on your own render thread.

The cascade works like this:

  1. React calls your function component to render.
  2. During that render, dispatch({ type: 'SOME_ACTION' }) executes synchronously.
  3. dispatch schedules a state update, which immediately queues another render.
  4. React starts that next render. Step 2 repeats.
  5. The call stack and React's work loop saturate. No other component on the page can render or respond to user input.

Blast radius extends beyond your single component. Because React's scheduler is cooperative and single-threaded, a runaway render loop in one subtree starves the entire application — routing, modals, form inputs, and network request handlers all freeze. In a micro-frontend architecture, this can propagate across module federation boundaries if the offending component is in a shared shell.

Common triggers engineers miss:

  • Calling dispatch at the top level of the component body (not inside any hook or handler).
  • Calling dispatch inside a useMemo or useCallback that has an unstable dependency, causing it to re-run every render.
  • Calling dispatch inside a useEffect without a dependency array, making it run after every render.
  • Deriving initial state with a side-effectful initializer that calls dispatch.

How to Fix It (The Solution)

Basic Fix — Move Dispatch Into useEffect

The most common offender: dispatch called unconditionally at render time.

import React, { useReducer } from 'react';

const initialState = { count: 0, data: null };

function reducer(state, action) {
  switch (action.type) {
    case 'SET_DATA': return { ...state, data: action.payload };
    case 'INCREMENT': return { ...state, count: state.count + 1 };
    default: return state;
  }
}

function MyComponent({ userId }) {
  const [state, dispatch] = useReducer(reducer, initialState);

- // ❌ WRONG: dispatch called directly in render body
- dispatch({ type: 'SET_DATA', payload: fetchUserSync(userId) });

+ // ✅ CORRECT: dispatch called inside useEffect, runs after render
+ React.useEffect(() => {
+   let cancelled = false;
+   fetchUser(userId).then((data) => {
+     if (!cancelled) dispatch({ type: 'SET_DATA', payload: data });
+   });
+   return () => { cancelled = true; };
+ }, [userId]); // dependency array prevents infinite loop

  return <div>{state.data?.name ?? 'Loading...'}</div>;
}

Enterprise Best Practice — Stabilize Dependencies & Guard Against Stale Closures

In larger codebases, the loop is often caused by an unstable reference inside a useEffect dependency array — typically an inline object or function recreated on every render.

import React, { useReducer, useEffect, useCallback } from 'react';

function DataContainer({ config }) {
  const [state, dispatch] = useReducer(reducer, initialState);

- // ❌ WRONG: `config` is a new object reference every render.
- // This effect runs on every render → dispatch → re-render → repeat.
- useEffect(() => {
-   dispatch({ type: 'LOAD_CONFIG', payload: config });
- }, [config]);

+ // ✅ CORRECT: Destructure primitives from config to stabilize deps.
+ const { endpoint, timeout } = config;
+ useEffect(() => {
+   dispatch({ type: 'LOAD_CONFIG', payload: { endpoint, timeout } });
+ }, [endpoint, timeout]); // Only re-runs when actual values change

- // ❌ WRONG: Dispatch inside useMemo causes side effects during render.
- const processedData = useMemo(() => {
-   dispatch({ type: 'INCREMENT' }); // side effect in memo = disaster
-   return expensiveCalc(state.data);
- }, [state.data]);

+ // ✅ CORRECT: useMemo is pure. Move dispatch to an event handler.
+ const processedData = useMemo(() => expensiveCalc(state.data), [state.data]);
+
+ const handleProcess = useCallback(() => {
+   dispatch({ type: 'INCREMENT' });
+ }, []); // dispatch is stable, no deps needed

  return <button onClick={handleProcess}>{processedData}</button>;
}

Key rules to enforce on every PR:

  • dispatch is safe to call in: event handlers, useEffect bodies, async callbacks, setTimeout/setInterval callbacks.
  • dispatch is never safe to call in: the component render body, useMemo, useCallback bodies (as a side effect), or render props.
  • dispatch itself has a stable identity — it never needs to be in a dependency array.

💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

Don't rely on code review alone. Automate detection at every stage of the pipeline.

1. ESLint — react-hooks/exhaustive-deps (Non-Negotiable)

// .eslintrc.json
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

exhaustive-deps will flag missing or unstable dependencies that commonly cause this loop. Treat every warning as an error in CI (--max-warnings 0).

2. Block Merges on Lint Failure (GitHub Actions)

# .github/workflows/lint.yml
name: Lint & Hook Audit
on: [pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npx eslint src/ --max-warnings 0

Set this as a required status check on your main/master branch protection rules. No merge without a clean lint pass.

3. React StrictMode (Development Guardrail)

// index.tsx — keep this in dev and staging, always
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Strict Mode intentionally double-invokes render functions in development to surface exactly this class of side effect. If your component calls dispatch during render, StrictMode will expose the loop immediately in local development before it ever reaches a PR.

4. Custom ESLint Rule (Advanced — Monorepo Scale)

For teams with large React codebases, add a custom rule to eslint-plugin-local-rules that statically detects dispatch( calls outside of approved hook/handler scopes. This is particularly effective when enforced via Husky pre-commit hooks combined with lint-staged.

# Husky + lint-staged setup
npx husky add .husky/pre-commit "npx lint-staged"
// package.json
"lint-staged": {
  "src/**/*.{ts,tsx}": ["eslint --max-warnings 0", "tsc --noEmit"]
}

This stops the infinite loop bug from ever leaving the developer's machine.

Related Diagnostics

"Part of the Performance Utility Matrix."

View all 219 Performance Tools →