Skip to solution
mediumFrontend

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

853 views
01

Understand 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.

rscnextjsserver-components
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for Next.js interviews — assumes what Server Components are. Difficulty: Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

The same page split badly and well, with what ships in each case
Run Playground
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>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 45 of 119 decoded in the React.js track. One more won't hurt.

Back to track