How to Fix React Minified Error #31: Objects Are Not Valid as a React Child
Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 5–15 mins
TL;DR
- What broke: A React component is attempting to render a plain JavaScript object, a Promise,
null-descended object, or an array of objects directly as a JSX child — React's reconciler hard-rejects this and white-screens the entire tree. - How to fix it: Extract the displayable scalar value (string, number) from the object before passing it into JSX, or serialize it with
JSON.stringify()during debugging. - Shortcut: Use our Client-Side Sandbox below to paste your component and auto-refactor the offending render expression instantly.
The Incident (What Does the Error Mean?)
Raw error output (development build):
Error: Objects are not valid as a React child (found: object with keys {id, name, email}).
If you meant to render a collection of children, use an array instead.
at throwOnInvalidObjectType (react-dom.development.js:14052)
Production (minified) equivalent:
Error: Minified React error #31;
visit https://reactjs.org/docs/error-decoder.html?invariant=31 for the full message
Immediate consequence: React's reconciler hits an invariant violation during the render phase. The component tree from the nearest error boundary — or the entire ReactDOM.render root if no boundary exists — unmounts completely. Users see a blank screen or a broken layout with zero feedback.
The Attack Vector / Blast Radius
This is not a recoverable warning. React throws synchronously during reconciliation. The blast radius depends entirely on where your error boundary is placed:
- No
<ErrorBoundary>→ full-page white screen. Every user hitting that route loses the entire UI. - Boundary at page level → that page's subtree is destroyed; navigation may still work.
- Boundary at component level → isolated failure, but the feature is dead.
Common trigger patterns that cause this at scale:
- API response shape change — backend team adds nesting to a previously flat field.
user.addresswas a string, now it's{ street, city, zip }. Every component rendering{user.address}explodes. - Redux/Zustand selector returning the wrong slice — selector returns the full sub-state object instead of the derived scalar.
- Async race condition — a
Promiseobject (not its resolved value) gets stored in state and passed to JSX before.then()resolves. Dateobjects —new Date()is an object.{someDate}in JSX is invariant #31.JSON.parse()result — parsed payload stored directly in state and spread into children without extraction.
How to Fix It (The Solution)
Basic Fix — Extract the scalar value before rendering
// BAD: rendering a full user object as a child
- <p>{user}</p>
// GOOD: render a specific string property
+ <p>{user.name}</p>
// BAD: API returns { value: "USD", symbol: "$" }, rendering the object
- <span>{currency}</span>
// GOOD: destructure at the usage site
+ <span>{currency.symbol}{amount}</span>
// BAD: Date object passed directly
- <time>{createdAt}</time>
// GOOD: serialize it
+ <time>{createdAt.toISOString()}</time>
Enterprise Best Practice — Defensive rendering with runtime type guards
Never trust the shape of external data in render. Enforce at the boundary between your data layer and your component tree.
// BAD: no type guard, assumes address is always a string
- function UserCard({ user }) {
- return <div>{user.address}</div>;
- }
// GOOD: explicit type guard + fallback, TypeScript-typed props
+ interface User {
+ address: string; // enforce scalar at the type level
+ }
+
+ function UserCard({ user }: { user: User }) {
+ const address = typeof user.address === 'string'
+ ? user.address
+ : JSON.stringify(user.address); // dev-mode escape hatch
+ return <div>{address}</div>;
+ }
// BAD: Redux selector returning full object slice
- const currency = useSelector((state) => state.payment.currency);
- // state.payment.currency = { code: 'USD', symbol: '$' }
- return <span>{currency}</span>;
// GOOD: selector derives the scalar
+ const currencySymbol = useSelector(
+ (state) => state.payment.currency.symbol
+ );
+ return <span>{currencySymbol}</span>;
For API-driven data: Use a schema validation library (Zod, Yup) at the fetch boundary so a shape regression throws a typed error before it reaches JSX, not inside the reconciler.
- const data = await fetch('/api/user').then(r => r.json());
- setUser(data); // unvalidated
+ import { z } from 'zod';
+ const UserSchema = z.object({ id: z.string(), name: z.string(), email: z.string() });
+ const raw = await fetch('/api/user').then(r => r.json());
+ const data = UserSchema.parse(raw); // throws ZodError on shape mismatch, never reaches JSX
+ setUser(data);
💡 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
Fix this class of bug before it ships. These checks run in under 30 seconds in any modern pipeline.
1. TypeScript strict mode — catches object-in-JSX at compile time
In tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true
}
}
TypeScript will reject ReactNode assignments from non-renderable types at build time.
2. ESLint rule — react/no-danger + custom no-object-in-jsx lint rule
Use eslint-plugin-react and enforce prop-types or TypeScript. For untyped codebases, add a custom ESLint rule or use eslint-plugin-react-hooks with strict return type checking.
3. Zod/Yup schema validation in your API layer (pre-render gate)
Validate every external API response against a schema before it enters React state. Wire this into your axios interceptors or fetch wrapper:
// api/client.js
export async function fetchUser(id) {
const raw = await fetch(`/api/users/${id}`).then(r => r.json());
return UserSchema.parse(raw); // pipeline fails loudly here, not in JSX
}
4. React Error Boundaries as a production safety net
Wrap every major route with an <ErrorBoundary>. This does not prevent the error but limits blast radius and enables error telemetry (Sentry, Datadog):
<ErrorBoundary fallback={<ErrorPage />} onError={(e) => Sentry.captureException(e)}>
<UserDashboard />
</ErrorBoundary>
5. CI pipeline gate (GitHub Actions example)
- name: Type Check
run: npx tsc --noEmit
- name: Lint
run: npx eslint src/ --max-warnings=0
- name: Unit Tests (render assertions)
run: npx jest --coverage
A tsc --noEmit failure on a PR blocks the merge. This is the cheapest possible gate against invariant #31 regressions from API shape changes.