Skip to the solution
MediumFrontend7 min read

RSC vs Client Components — when do you add 'use client' in Next.js 15?

853 views

The problem

Question presented to candidate: "How do you decide which components get "use client"?"

What a strong answer should cover:

  • The default in the App Router is a Server Component. "use client" is an opt out, not an opt in — you add it only when you need something the server cannot do.
  • The trigger list is short and concrete: state, effects, event handlers, browser APIs, refs to DOM nodes, and any hook that depends on those.
  • It is an import-graph boundary, not a per-file label. Everything a Client Component imports — transitively — joins the client bundle. That is why placement matters so much.
  • So the rule is push it down to the leaves. A "use client" near the root converts the whole tree and you keep all the constraints while losing the benefit.
  • The composition escape hatch: a Client Component can receive Server Components as children. The parent being client does not force its children to be.
  • Props crossing the boundary must be serialisable — no functions, no class instances.
  • Practical smell test: if a component only formats and displays data, it should be a Server Component; if it responds to the user, it is a leaf that needs the directive.

Clarifying questions expected:

  • "Does this component actually need interactivity, or does only a small part of it?" — that decides where the boundary goes.
  • "What does this file import?" — a heavy library pulled in by a client component ships too.

Code / implementation expected: Optional. Showing a component split into a server shell and a client leaf is the substance.

  • rsc
  • nextjs
  • server-components

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read · 8 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactThe same page split badly and well, with what ships in each case
import { useState } from "react";

// No RSC runtime in a browser playground, so this is an annotated comparison
// of the two file layouts plus a live bundle-impact estimate.

const BAD = `// app/article/page.jsx
"use client";                       //  the whole page is now a client module
import { marked } from "marked";           // 40KB -> shipped to the browser
import { formatDistance } from "date-fns"; // 20KB -> shipped
import { useState } from "react";

export default function ArticlePage({ article }) {
  const [open, setOpen] = useState(false);   // <- the ONLY reason for the directive

  return (
    <article>
      <h1>{article.title}</h1>
      <time>{formatDistance(article.date, new Date())}</time>
      <div dangerouslySetInnerHTML={{ __html: marked(article.body) }} />
      <button onClick={() => setOpen(!open)}>Comments</button>
      {open && <Comments articleId={article.id} />}
    </article>
  );
}`;

const GOOD = `// app/article/page.jsx     — NO directive: a Server Component
import { marked } from "marked";           // stays on the server
import { formatDistance } from "date-fns"; // stays on the server
import Disclosure from "./Disclosure";     // the client leaf

export default async function ArticlePage({ params }) {
  const article = await db.article.find(params.id);   // no API route needed

  return (
    <article>
      <h1>{article.title}</h1>
      <time>{formatDistance(article.date, new Date())}</time>
      <div dangerouslySetInnerHTML={{ __html: marked(article.body) }} />

      {/* Disclosure is a client component, but Comments is passed as
          CHILDREN — already rendered on the server, so it does NOT ship. */}
      <Disclosure label="Comments">
        <Comments articleId={article.id} />
      </Disclosure>
    </article>
  );
}

// app/article/Disclosure.jsx
"use client";                        // the directive lives HERE, on the leaf
import { useState } from "react";

export default function Disclosure({ label, children }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(!open)}>{label}</button>
      {open && children}
    </>
  );
}`;

const SHIPS = [
  ["marked (markdown renderer)", "~40KB", false, true],
  ["date-fns (formatting)", "~20KB", false, true],
  ["the page component itself", "~2KB", false, true],
  ["the Disclosure toggle", "~0.4KB", true, true],
];

export default function App() {
  const [good, setGood] = useState(true);
  const total = SHIPS.filter(([, , inGood, inBad]) => (good ? inGood : inBad))
    .reduce((n, [, size]) => n + parseFloat(size.replace(/[^\d.]/g, "")), 0);

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 640 }}>
      <div style={{ display: "flex", gap: 8, marginBottom: 10 }}>
        <button onClick={() => setGood(false)} style={{ fontWeight: !good ? "bold" : "normal" }}>
          ❌ directive on the page
        </button>
        <button onClick={() => setGood(true)} style={{ fontWeight: good ? "bold" : "normal" }}>
          ✅ directive on the leaf
        </button>
      </div>

      <pre style={{ background: good ? "#f2f9f2" : "#fdf0f0", border: "1px solid #ddd",
                    borderRadius: 8, padding: 12, fontSize: 12, overflowX: "auto", whiteSpace: "pre-wrap" }}>
{good ? GOOD : BAD}
      </pre>

      <h4 style={{ margin: "12px 0 6px" }}>What reaches the browser</h4>
      <table style={{ width: "100%", fontSize: 13, borderCollapse: "collapse" }}>
        <tbody>
          {SHIPS.map(([name, size, inGood, inBad]) => {
            const ships = good ? inGood : inBad;
            return (
              <tr key={name} style={{ borderBottom: "1px solid #eee", opacity: ships ? 1 : 0.4 }}>
                <td style={{ padding: "3px 8px 3px 0" }}>{name}</td>
                <td style={{ color: "#666" }}>{size}</td>
                <td style={{ color: ships ? "#a33" : "#161" }}>{ships ? "ships" : "stays on the server"}</td>
              </tr>
            );
          })}
        </tbody>
      </table>
      <p style={{ fontSize: 13, marginTop: 6 }}>
        client bundle: <strong>{total.toFixed(1)}KB</strong>
      </p>

      <p style={{ fontSize: 13, color: "#666" }}>
        The behaviour is identical in both. One <code>useState</code> was the
        only thing forcing the directive, and moving it to a leaf that takes{" "}
        <code>children</code> keeps the markdown renderer, the date library and
        the page itself entirely on the server.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 45 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextMedium

What are React custom hooks and when should you use them?

Continue