Skip to content

Objects are not valid as a React child: read the keys

Objects are not valid as a React child (found: object with keys {…}): what React received, how to find it from the message alone, and a fix that sticks.

React reactjs debugging typescript
BKG
Reading Progress

On This Page

On React 18, handing a Promise to JSX threw. On React 19 it renders. That one change quietly broke half the folklore about Objects are not valid as a React child, and it's why the top search results can send you hunting for the wrong thing.

The message is not vague. It's the most precise error React has, if you know which part to read.

The red screen, in dev and in production

This is what my repro printed on React 19.3.1 in a development build (jsdom, no framework, so no overlay chrome):

Error: Objects are not valid as a React child (found: object with keys {status, data}). If you meant to render a collection of children, use an array instead.
    at throwOnInvalidObjectTypeImpl (react-dom-client.development.js:6461:13)
    at throwOnInvalidObjectType (react-dom-client.development.js:6475:11)
    at createChild (react-dom-client.development.js:6764:11)
    at reconcileChildrenArray (react-dom-client.development.js:7033:25)
    at reconcileChildFibersImpl (react-dom-client.development.js:7357:20)
An error occurred in the <p> component.

Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.

The same bug with a Date in the JSX swaps the parenthesis for (found: [object Date]). An Error instance gives (found: [object Error]).

React 18.3.1 words the component stack differently: The above error occurred in the <p> component: followed by an indented at list of components. React 19 prints only An error occurred in the <p> component. in this setup. Both name the host element that was being filled, not your component. Keep that in mind, because it matters in the hunt.

Production build, client bundle, React 19.3.1:

Minified React error #31; visit https://react.dev/errors/31?args[]=object%20with%20keys%20%7Bstatus%2C%20data%7D for the full message or use the non-minified dev environment for full errors and additional helpful warnings.

The args[] value is URL-encoded and holds the same found: text. Paste it into a decoder or read it: object with keys {status, data}. The template, from the error decoder page, is Objects are not valid as a React child (found: %s). If you meant to render a collection of children, use an array instead.

(Aside: renderToString from react-dom/server kept the full text even with NODE_ENV=production in my run. Only the client bundle minified it.)

A repro that runs in a terminal

I wanted something with no framework between me and the throw, so this is esbuild plus jsdom. In a browser through Vite or Next you'll get the same message in the overlay. I didn't run those.

mkdir child-repro && cd child-repro
npm init -y
npm i --save-exact react@19.3.1 react-dom@19.3.1
npm i -D --save-exact esbuild@0.28.2 jsdom@25.0.1 typescript@7.0.2 @types/react@19.3.0
mkdir src
// src/api.ts
export type ApiError = { status: number; data: { message: string } };
export async function loadUser(id: string): Promise<{ name: string; joined: Date }> {
  if (id === "missing") throw { status: 404, data: { message: "no such user" } } satisfies ApiError;
  return { name: "Ada", joined: new Date("2024-03-01") };
}
// src/UserCard.tsx
import { useEffect, useState } from "react";
import { loadUser, type ApiError } from "./api";

export function UserCard({ id }: { id: string }) {
  const [user, setUser] = useState<{ name: string; joined: Date } | null>(null);
  const [error, setError] = useState<ApiError | null>(null);
  useEffect(() => { loadUser(id).then(setUser, setError); }, [id]);
  if (error) return <p role="alert">Failed: {error}</p>;   // throws: object with keys {status, data}
  if (!user) return <p>loading</p>;
  return <p>{user.name} joined {user.joined}</p>;           // throws: [object Date]
}
// src/main.tsx
import { Suspense, StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { UserCard } from "./UserCard";
export function mount(el: HTMLElement, id: string) {
  createRoot(el).render(
    <StrictMode><Suspense fallback="loading"><UserCard id={id} /></Suspense></StrictMode>
  );
}
// run.cjs
const { JSDOM } = require("jsdom");
const dom = new JSDOM('<div id="r"></div>');
global.window = dom.window; global.document = dom.window.document;
const { mount } = require("./out.cjs");
mount(document.getElementById("r"), process.argv[2] || "ok");
setTimeout(() => console.log("HTML:", document.getElementById("r").innerHTML), 300);
npx esbuild src/main.tsx --bundle --platform=node --format=cjs --jsx=automatic \
  --outfile=out.cjs --external:react --external:react-dom
node run.cjs missing   # found: object with keys {status, data}
node run.cjs ok        # found: [object Date]

Confirmation signal: both runs end with an empty HTML: line, because the whole tree unmounted. If node run.cjs ok prints a paragraph, you're not on 19.3.1 or you edited the Date line. If you see [object Error] where I show keys, jump to cause 3 below.

The two throwing lines sit in the same component, and the stack names <p> for both. Only the found: text tells them apart.

Did 19 change what counts as an invalid child?

Partly. I installed each version and rendered the same set of values through createRoot inside an error boundary.

Value as a child 18.3.1 19.0.7 19.1.10 19.2.7 19.3.1
{ name, id } plain object throws, keys listed same same same same
new Date() throws, [object Date] same same same same
new Error() throws, [object Error] same same same same
memo(...) / forwardRef(...) object throws, keys incl. $$typeof same same same same
a Promise throws, [object Promise] renders the resolved value same same same
async function component throws, [object Promise] throws, "async/await is not yet supported in Client Components" throws, "async Client Component" same same
element made with the old react.element symbol renders fine throws, "older version of React" same same same
a Map renders its values, warns renders its values renders its values renders its values, warns same

Two rows deserve a second look.

The Promise row is the one I got wrong going in. I wrote the test expecting a Promise to blow up on every version, and on 19 the paragraph said x. React 19 reads a thenable child the way use() would. In renderToString it throws a different error entirely: A component suspended while responding to synchronous input. So [object Promise] in a new error report almost certainly comes from an 18 runtime or a library bundling its own old copy.

And the async function component. 19.0.7 words it as async/await is not yet supported in Client Components, only Server Components. From 19.1.10 on it reads:

<Name> is an async Client Component. Only Server Components can be async at the moment. This error is often caused by accidentally adding `'use client'` to a module that was originally written for the server.

If that's your error, you're in the wrong article's neighbourhood: remove async from the client component or move the file back to the server side.

react.dev's versions page lagged npm on the day I checked: it listed 19.3.0 as latest, while npm view react dist-tags said latest was 19.3.1. I used the npm numbers.

What you actually rendered

React was given a value that is not a string, a number, an element, null, a boolean, or an array of those. Objects have no DOM representation, so it throws instead of printing [object Object] into your page.

The fix is never "convert it to a string". The fix is to find out which value, and then decide what a human should see for it. An API error needs a message. A date needs a format. A component needs to be a <Component />, not a value.

One thing the message gives you for free: the found: text is built from the value itself. For a plain object, you get its keys. For anything else, you get Object.prototype.toString's answer, which is why a Date and an Error are named but their contents aren't.

Inside throwOnInvalidObjectType

I read this in the shipped react-dom bundles for 18.3.1 and 19.3.1, because the GitHub repo wasn't reachable from the machine this ran on. The function lives in the reconciler's child-fiber code (ReactChildFiber.js in the source tree). The 18.3.1 body is three lines:

  1. Call Object.prototype.toString on the child.
  2. If the answer is [object Object], build object with keys {…} by joining Object.keys with commas. Otherwise use the toString answer as is.
  3. Throw.

The 19 version does the same thing, with one addition in front: a check for the legacy element symbol, which throws a separate message about "a React Element from an older version of React". That's where the last row of the table comes from. React 19 changed the symbol that marks a React element, so JSX produced by an old precompiled library, or by a compiler that inlines element objects, no longer looks like an element. It falls through to this function and gets its own error.

Why does the stack name <p> and not UserCard? Because the throw happens while React reconciles the children of that host node. UserCard already returned successfully. It gave React a <p> with a bad child inside.

The frames help a little. createChild called from reconcileChildrenArray means the bad value had siblings, like Failed: text next to {error}. A lone <p>{error}</p> takes a shorter path without the array frames.

Since the check keys off toString, objects with a null prototype still list their keys. I checked a { seconds, nanoseconds } timestamp-shaped object and got object with keys {seconds, nanoseconds}. Useful when a database SDK hands you one.

How to prove which object it is

Wrong turns first, because mine cost me a while.

Wrong turn 1: wrap it in String(). Failed: {String(error)} stops the crash. The paragraph then reads Failed: [object Object]. The exception was the only thing telling me something was wrong, and I'd muted it. Dropped, but not before it passed code review in my head.

Wrong turn 2: use() plus try/catch. In an earlier version of the repro I read the promise with use() inside a try, and rendered the caught value. React logged `use` was called from inside a try/catch block. This is not allowed and can lead to unexpected behavior and then threw found: [object Error]. The thing I caught and rendered was React's own internal suspend signal. My real bug was a catch block that rendered {e}, and the use() rule was a second bug stacked on it. I only saw both because the [object Error] text didn't match the {status, data} I expected.

Wrong turn 3: the Promise theory. Old answers say [object Promise] means you forgot await. That predicts a Promise error on every version, so I tested it first. On 19 the Promise rendered, so the theory couldn't explain an error on 19 at all. Dropped after one run of the table above.

Now the path I'd walk in someone else's app:

# 1. Read only the text after "found:". It names the shape.
#    object with keys {…}   -> a plain data object. Step 2.
#    [object Date]          -> a Date. Step 3.
#    [object Error]         -> a caught error. Step 4.
#    keys include $$typeof  -> a component object (memo/forwardRef). Step 5.
#    [object Promise]       -> React 18, or an old bundled React. Step 6.

# 2. Search for the exact key names in JSX. In my repro:
grep -rn "status" src --include=*.tsx

# 3. Search for date-looking values dropped straight into braces:
grep -rnE "\{[a-zA-Z.]*(date|At|joined|time)[a-zA-Z.]*\}" src --include=*.tsx

# 4. Search every catch and every onError that stores or renders the raw value:
grep -rnE "catch \(|setError\(" src

# 5. Look for a component used as a value instead of <Component />:
grep -rnE "\{[A-Z][A-Za-z]+\}" src --include=*.tsx

# 6. One React or two?
npm ls react react-dom
#    Two versions -> the duplicate-React branch of the hook-call error applies,
#    see the Invalid hook call article linked below.

In production, take the args[] text from the minified message first. It gives you the same key list, so step 1 works without a dev build.

found: …
├─ object with keys {a, b}  ──► grep keys ──► render a field, not the object
├─ [object Date]            ──► format it   (<time dateTime>)
├─ [object Error]           ──► use error.message, or store a string
├─ keys with $$typeof       ──► <Component /> instead of {Component}
└─ [object Promise]         ──► React 18, or an older bundled React

Here's how often I'd bet on each cause. These ranks are my judgment from the code I've read, not a measured count.

Rank Cause How often The tell Fix
1 A fetched or stored object rendered whole (API body, error body, i18n result, CMS field) Most cases object with keys {…} and the keys match your data Option A below
2 A Date rendered whole Common [object Date] Option B
3 A caught error rendered whole Common [object Error], a catch or onError nearby Option C
4 A memo/forwardRef component object rendered as a value Occasional keys include $$typeof and render or type Option D
5 A Promise child Rare on 19 [object Promise] React 18 only; upgrade, or await before render
6 An old element from a second or bundled React Rare the "older version of React" message See Invalid hook call article
7 use() inside try/catch, with the caught value rendered Rarest [object Error] plus the use warning Remove the try/catch

Cause 7 is the one that took longest to see, and the tell was the mismatch: the keys I expected weren't in the message.

The tell for all of them is the same. Read the found: text before you read a single line of your code.

Fix it where the value is born

Why this works: React can only display values it can map to DOM. So the repair is to pick a displayable piece at the point where you build the JSX, not to coerce the whole object.

-  if (error) return <p role="alert">Failed: {error}</p>;
+  if (error) return <p role="alert">Failed ({error.status}): {error.data.message}</p>;

-  return <p>{user.name} joined {user.joined}</p>;
+  return (
+    <p>{user.name} joined <time dateTime={user.joined.toISOString()}>{fmt.format(user.joined)}</time></p>
+  );

Pick by cause:

  • Option A, a data object. Render one field, or map the object into elements. The change belongs in the component that builds the JSX. Ship this one.
  • Option B, a Date. Format with Intl.DateTimeFormat and give it a fixed timeZone if the page is server-rendered. toLocaleString() in render is the classic way to fix this error and start a hydration mismatch, which the hydration mismatch article covers.
  • Option C, an error. Store error.message (a string) in state, or a small object you control, and render a field of it.
  • Option D, a component object. {Card} where Card came from memo(...) or forwardRef(...) must be <Card />. A plain function used as a value doesn't throw; it logs a warning and renders nothing, which is why this one hides.

The silencers, and what each costs:

  • String(x) or JSON.stringify(x) in the JSX. Users see [object Object] or a raw dump. The bug is now in production and quiet.
  • A root error boundary with a generic fallback. It catches the exception and keeps the cause. You've swapped a crash for a blank card.
  • as any or as ReactNode. See the next section, because TypeScript is your best net here and this switches it off.

If ReactNode typing and catch blocks keep producing this, the related undefined-property article covers the other half of the same render-before-data habit.

Make the type system refuse to render it

The class of bug is "I put a value in JSX without deciding how a person sees it". Remove the ambiguity by modelling the states, and make each branch render only strings and numbers.

// src/UserCardFixed.tsx
import { useEffect, useState } from "react";
import { loadUser, type ApiError } from "./api";

type State =
  | { kind: "loading" }
  | { kind: "ready"; user: { name: string; joined: Date } }
  | { kind: "failed"; error: ApiError };

const fmt = new Intl.DateTimeFormat("en-GB", { dateStyle: "medium", timeZone: "UTC" });

export function UserCard({ id }: { id: string }) {
  const [state, setState] = useState<State>({ kind: "loading" });
  useEffect(() => {
    let live = true;
    loadUser(id).then(
      (user) => live && setState({ kind: "ready", user }),
      (error: ApiError) => live && setState({ kind: "failed", error }),
    );
    return () => { live = false; };
  }, [id]);

  switch (state.kind) {
    case "loading": return <p>loading</p>;
    case "failed":  return <p role="alert">Failed ({state.error.status}): {state.error.data.message}</p>;
    case "ready":   return (
      <p>{state.user.name} joined <time dateTime={state.user.joined.toISOString()}>{fmt.format(state.user.joined)}</time></p>
    );
  }
}

I ran both inputs against this version on 19.3.1. missing renders Failed (404): no such user and ok renders Ada joined <time datetime="2024-03-01T00:00:00.000Z">1 Mar 2024</time>.

The live flag is there because the effect fetches. If you let a data library or a Server Component own the fetch, the state union disappears, and so does half the surface for this bug.

Does TypeScript catch the original? Mostly. With strict on and @types/react 19.3.0, tsc flagged Type 'ApiError' is not assignable to type 'ReactNode' and the same for Date. It also flagged unknown and Error | null. It did not flag any, and it did not flag Promise<string>, because the current ReactNode type allows promises, matching what 19 does at runtime. The any hole is the one to close: every catch (e) you let become any is a path back to this error.

Catching it before it reaches a user

  • Types. tsc --noEmit in CI with strict. Treat an any in state that reaches JSX as a review comment. Keep one copy of @types/react; npm ls @types/react should show a single version.
  • Lint. I did not find a rule I could verify for this specific error, so I'm not naming one. The types do the work.
  • Tests. Render the error state and the date state in React Testing Library and assert on visible text. A test that only checks "renders without crashing" would have passed before my fix only if it never hit the failing branch, which is the usual way this ships.
  • Browser tests. Make Playwright or Cypress fail on any console.error. The throw logs one.
  • Production signal. Group error-boundary reports by the minified code. A new #31 after a deploy points at a changed API payload or a new date field. My own rule: one #31 that wasn't there yesterday gets looked at the same day.
  • Version safety. The throw for plain objects, dates and errors is the same on 18.3.1 through 19.3.1. What moved was Promises and legacy elements. When you upgrade from 18 to 19, retest any screen that renders data from a library that bundles its own React.
  • Team convention. Anything that goes inside {} in JSX is a string, a number, or an element. If it's anything else, name the conversion on its own line above the return.

Worth pinning

  • Read the text after found: before anything else. It's a diagnosis.
  • object with keys {…} means grep those key names. [object Date], [object Error] and $$typeof each point at one specific habit.
  • [object Promise] on React 19 is a stale runtime, not your missing await.
  • Anything that silences the throw without choosing what the user sees has hidden the bug.

Questions people ask about this error

How do I fix "Objects are not valid as a React child"? Read the found: text, find that value in your JSX, and render a string, number or element built from it. On React 18.3 through 19.3 the rule is the same for plain objects, dates and errors.

Can I render a Date in React? Not directly. Format it with Intl.DateTimeFormat or toISOString() and render the result. On a server-rendered page, fix the time zone and locale, or the server and client will format it differently; see the hydration mismatch article.

Why does React say "A React Element from an older version of React was rendered"? On React 19 it means an element object was created with the old element symbol, usually by a library that bundled its own React or inlined JSX. Check npm ls react react-dom first, then read the Invalid hook call article on duplicate copies.

Why is my async component throwing instead of rendering? On 19.0.7 through 19.3.1 a client component declared async throws an error saying only Server Components can be async (the wording changed after 19.0). Only Server Components can be async.

Reactreactjsdebuggingtypescript

BKG

Engineer and writer behind CODELZ. I read the stack trace, reproduce the bug, and explain why it happens, not just how to silence it. Plus honest reviews of the books and tools worth your time.

Comments