skip to content

In a large React codebase using Server Components, everything passed as a prop into a Client Component is serialized into a payload the browser downloads and can read. How do you set the standard for what is allowed to cross that boundary?

level: principalimportance: should knowfreq 28%

answer

  1. a prop here is a published document
  2. visibility, cost, and shape stability
  3. select fields, never forward the row
  4. one mapper per entity, one named type
  5. fat props mean the boundary is misplaced

basics

~20 s

Treat the server-to-client prop boundary as a published API, not an internal call. Define an explicit view model per boundary, map to it in one auditable place per entity, and review it for three things: what the user can read, how many bytes it costs, and how stable the shape is.

solid answer

~60 s

The boundary is a serialization boundary, which makes it three things at once: a **visibility** boundary, because every prop lands in a payload the user can open in devtools; a **cost** boundary, because those bytes are downloaded and parsed, and on first load the payload is inlined into the streamed HTML alongside the markup it produced; and a **contract** boundary, because the shape you pass becomes something two sides depend on. My standard follows from that. Define a named view model per boundary and select fields explicitly rather than forwarding whatever the data layer returned. Put the projection in one function per entity so there is a single place to audit for leaked fields. Enforce it with types and code review — a domain object appearing directly in a client component's props is a review comment, not a style preference. And keep client components' prop surface narrow enough that the projection is small; a fat prop object is usually a sign the boundary is drawn in the wrong place.

code

typescript · 23 lines
typescript
// boundary contract in one place
export type ProfileView = {
  id: string;
  displayName: string;
  avatarUrl: string | null;
  joinedAt: Date;
};

export function toProfileView(user: {
  id: string;
  displayName: string;
  avatarUrl: string | null;
  createdAt: string;
  passwordHash: string;
  internalRiskScore: number;
}): ProfileView {
  return {
    id: user.id,
    displayName: user.displayName,
    avatarUrl: user.avatarUrl,
    joinedAt: new Date(user.createdAt),
  };
}

go deeper

for a junior

Know the habit: pass the specific fields a component needs, not the whole object you fetched. Remember that what you pass is downloadable by the user, so it is not a private implementation detail.

for a middle

Be able to justify the habit with the mechanism — props are serialized into a payload the browser downloads and parses — and show a projection that selects fields explicitly instead of spreading a row.

for a senior

Demonstrate that you weigh visibility, byte cost and shape stability together, and that you put the projection in one auditable place per entity rather than inline in each server component.

for a principal

Own the standard and its enforcement: named boundary types, one mapper per entity, a fixed review question, and an honest account of when the indirection is not yet worth paying for.

## Why this needs a standard at all In a client-only React app, props are a private implementation detail: passing the whole object costs a pointer copy and nobody outside the process sees it. Server Components change that quietly. The same syntax now means "write this value into a document the browser downloads". Teams that do not notice the change keep their old habits and get three problems, none of which shows up as a failing test. ## Force one: visibility Everything serialized is readable by the user. If a server component passes the row it got from the database, then the internal flags, the soft-delete marker, the internal cost fields, the moderation notes and anything else on that row are in the payload — even if the client component renders three of the fields. The UI looks correct. The leak is real. This is the argument for selecting fields explicitly rather than spreading. `{...user}` is convenient and forwards everything; `{ id, displayName, avatarUrl }` forwards a decision. In review, the question to ask of any boundary prop is not "does the component use this?" but "is the user allowed to see this?" — and the two are different questions that a spread conflates. A related rule worth writing down: secrets never cross, not even transiently. An API token passed down "just so the client can construct a URL" is published the moment it is serialized. ## Force two: cost The payload is bytes over the wire and work on the main thread to parse and instantiate. On first load, the RSC payload is inlined into the streamed HTML alongside the rendered markup, so a fat prop object is paid for on top of the HTML it produced. Passing a 400-row array so the client component can render the first ten is a real regression that no local dev run will reveal. The standard here: pass what is rendered, paginate or slice on the server, and prefer identifiers over aggregates when the client's job is to reference something rather than display it. ## Force three: the shape is now a contract Once a projection exists, both sides depend on it, and changes ripple. The mitigation is ordinary API discipline applied to something that does not look like an API: give the shape a name and a type, keep the mapping in one function per entity (`toProfileView(user)`), and let both the server component and the client component's props type refer to that one definition. Then a field rename is a compile error in one place instead of a runtime `undefined` in the browser. A useful smell test: if you cannot write the boundary type down in five or six lines, the client component is probably doing too much and the boundary is drawn too high. ## Making the standard hold A rule that lives in someone's head does not survive a growing team. What actually works: - **A named type per boundary**, so a domain type in a client component's props signature is visible in review. - **One mapper per entity**, so an audit for leaked fields is a finite list of functions to read rather than a grep across every server component. - **A review question with a fixed form**: for each prop, may the user see it, how big is it, and who else depends on the shape. - **A default of narrow**: add a field when a component needs it, rather than passing the aggregate and trimming later — trimming later never happens. ## The tradeoff to be honest about This costs indirection. Every boundary gains a mapping function and a type, and in a small app or a prototype that ceremony is not worth it — passing the row is faster to write and nothing bad happens at that scale. The judgment is about when the codebase crosses from "one person holds the whole thing in their head" to "several teams add server components weekly". At that point the implicit standard has already failed silently, and the visible cost of the explicit one is much cheaper than the invisible cost of the leaks and the payload growth. The compact statement: the server-to-client boundary is the only place in a React tree where a prop is simultaneously a security decision, a performance decision and a versioning decision, so it deserves the same explicitness you would give a public endpoint.

  • A client component only renders three fields, so is passing the whole row actually harmful?
    Yes, on two counts. Every unused field is in a payload the user can read, so internal columns leak regardless of what is rendered, and the bytes are downloaded and parsed on top of the markup they produced. Rendering fewer fields than you pass is precisely the failure mode.
  • How would you detect boundary props that have quietly grown over time?
    Make them findable: named boundary types and one mapper per entity turn the audit into reading a finite list of functions. Beyond that, watch payload size as a tracked metric, so a projection that started small and grew shows up as a number rather than as a review someone happened to do.
  • When is this discipline not worth it?
    In a prototype or a small app where one person holds the whole shape in their head, the mapping functions and named types are pure ceremony. The point to adopt it is when server components are being added faster than any one person reviews them — by then the implicit standard has already failed.
  • Does the same standard apply to Server Function arguments and return values?
    Yes, and arguably more strictly. Arguments arrive from a browser-reachable endpoint, so they must be validated rather than trusted, and the return value is serialized straight back into the client, so it deserves the same explicit projection as any prop crossing the boundary.

saying these in an interview costs you the question

  • Says payload contents are not visible to users
  • Treats prop selection as a performance-only concern
  • Forwards whole domain objects and trims on the client
  • Assumes unused props cost nothing because they are not rendered
  • Applies the ceremony everywhere regardless of codebase size

context