skip to content

In a React Server Component you fetch a record with an ORM and pass the returned model instance straight into a Client Component as a prop, and React throws that only plain objects can be passed. Why does a Date prop work but the model instance not, and how do you fix it?

level: middleimportance: should knowfreq 48%

answer

  1. fixed list of built-ins, then plain data
  2. prototype and methods cannot cross
  3. fails loudly rather than shipping a stub
  4. project to a view model at the boundary
  5. spread is a patch, mapping is the design

basics

~20 s

The RSC payload has built-in encodings for a fixed set of types including Date, but any other object must be a plain object built from an initializer. A class instance is defined by its prototype and methods, which cannot travel, so map it to a plain shape first.

solid answer

~50 s

React's serializer knows how to encode a fixed set of types — primitives, arrays, plain objects, `Date`, `Map`, `Set`, typed arrays, `FormData`, JSX, promises and Server Functions — and `Date` is on that list, so it is reconstructed as a real `Date` on the client. Anything else must be a plain object, meaning one created from an object initializer or a spread, whose prototype is `Object.prototype`. An ORM model instance is not: its meaning lives in a class prototype with methods and possibly getters, and there is no way to recreate that class in the browser, so React refuses rather than silently shipping a stripped copy. Objects with a `null` prototype are rejected for the same reason. The fix is to map at the boundary: build an explicit plain object with the fields the client needs. `{...model}` copies own enumerable fields into a plain object and often works, but the deliberate mapping is better — it also stops internal columns leaking into a payload the browser can read.

code

javascript · 9 lines
javascript
// one place per entity, auditable and typed
export function toProfileView(user) {
  return {
    id: user.id,
    displayName: user.displayName,
    avatarUrl: user.avatarUrl,
    joinedAt: new Date(user.createdAt),
  };
}

go deeper

for a junior

Know the shape of the fix: build a plain object with the fields the client needs instead of passing whatever the data layer returned. Recall that Date is special-cased and works, while your own classes do not.

for a middle

Explain the two-part rule — a fixed list of encodable built-ins, then a structural plain-object test — and say why React errors rather than stripping the prototype: a silently degraded object would fail later, in the browser.

for a senior

Show that you project deliberately: an explicit view model per boundary, recursive cases handled at the query level, and awareness that the projection is also the point where you decide which internal fields never reach the browser.

for a principal

Own the pattern across the codebase — one mapper per entity, named boundary types, and review that treats the projection as a published contract rather than a convenience, so leaked fields and shape churn are caught before they ship.

## Two different questions hiding in one error "Why does `Date` work and my model not?" is really two questions: what does the serializer support natively, and what is a *plain object*? The first is a closed list. React's RSC payload format is not generic — it has purpose-built encodings for a specific set of types, and `Date`, `Map`, `Set`, `TypedArray`, `ArrayBuffer` and `FormData` are on it. When a `Date` crosses, React writes a marker plus a timestamp and rebuilds a real `Date` on the client. There is no mechanism for you to add your own class to that list. The second is a structural test. For any other object, React requires a plain object: one produced by an object initializer (`{ a: 1 }`), a spread, or equivalent — an object whose prototype is `Object.prototype`, carrying data and nothing else. An instance of `class User` fails, and so does an object created with `Object.create(null)`, since React treats a missing prototype as an unusual object rather than a plain one. ## Why React refuses instead of degrading It could, in principle, copy the own enumerable fields and hand the client a data bag. It deliberately does not, and the reasoning is worth stating in an interview because it shows you understand the boundary. A class instance's *identity* is its prototype: `money.format()`, `user.isAdmin()`, `doc.save()`. If React silently shipped a shape without the prototype, the client would receive something that looks like the object it asked for and then blows up at the first method call, at runtime, in the browser, usually in production. Failing loudly on the server, at render time, with the prop path in the message, is the strictly better trade. The same logic explains functions: React does not drop them the way `JSON.stringify` does — it errors. ## The fix: map at the boundary The idiomatic fix is an explicit projection in the server component — a view model. ```jsx export default async function Page({ params }) { const user = await db.user.findUnique({ where: { id: params.id } }); return ( <ProfileCard user={{ id: user.id, displayName: user.displayName, joinedAt: new Date(user.createdAt), }} /> ); } ``` Three things improve at once. The prop is serializable. The payload shrinks to what the UI actually uses. And internal columns — password hashes, internal flags, billing identifiers — stop travelling into a document the user can open in devtools, because you listed the fields instead of forwarding an aggregate. ## The quick fix and its limits `{...model}` produces a plain object from the instance's own enumerable properties, which is often enough to get moving. Be aware of what it does not do: it does not include prototype getters (a computed `fullName` defined on the class simply is not there), it does not deep-convert nested class instances (a nested decimal or document object still throws), and it forwards *every* own field, which is exactly the over-exposure you were trying to avoid. Treat it as a debugging step, not the design. A nested case is the one people hit second: `{ ...order, lines: order.lines }` still fails if each line is itself a model instance, because serialization is recursive. Map the whole tree, or select the fields at the query level so the ORM returns plain rows in the first place. ## Where the mapping should live Once more than one route needs the same projection, put it in one function per entity — `toProfileView(user)` — and call it from the server components. That gives you a single place to audit for leaked fields, a single place to change when the client's needs change, and a type you can name in TypeScript so the boundary shape is explicit rather than accidental. ## Saying it in one sentence "The payload can encode a fixed set of built-ins plus plain data; a class instance is defined by behaviour that cannot cross, so I project it into an explicit view model at the boundary — which is also where I decide what the browser is allowed to see."

  • Why does React error on a class instance instead of just copying its data fields?
    Because the copy would look right and then fail at the first method call, in the browser, at runtime. The instance's identity is its prototype, so silently dropping it produces an object that lies about what it is. Erroring on the server, at render, with the prop path is far cheaper to fix.
  • Does {...model} always work as a fix?
    Often, but it is a patch. It captures only own enumerable properties, so prototype getters vanish, it does not convert nested class instances so those still throw, and it forwards every internal field into a payload the user can read. Prefer listing the fields you want.
  • An array of ORM rows fails even though each row looks like a data object. What is going on?
    Serialization is recursive: React inspects each element, and if the ORM returns model instances rather than plain rows, every element fails the plain-object test. Map the array explicitly, or ask the ORM for plain results with a field selection.

saying these in an interview costs you the question

  • Says class instances become plain objects automatically
  • Thinks adding a toJSON method makes the class serializable
  • Believes the error is about payload size
  • Assumes spreading also brings across prototype getters
  • Forwards the whole database row and calls it fine

context