Opting in
Here is the counter from the React course, ported to Next.js:
// app/like-button.js "use client"; import { useState } from "react"; export default function LikeButton() { const [likes, setLikes] = useState(0); return ( <button onClick={() => setLikes(likes + 1)}> ♥ {likes} </button> ); }
Decode it:
"use client"must be the first line of the file, before imports. It is a directive, not a function call.- Everything below it is exactly the React you already know.
- Without the directive, the build fails:
useStateandonClickare not allowed in server components.
It marks a boundary, not one file
The rule that makes the directive matter: everything a client file imports becomes client code too. The browser must be able to run the whole subtree that a client component renders, so the directive is contagious in one direction, downward through imports.
Picture the component tree. "use client" draws a line across it, and the entire subtree below that line ships to the browser.
That leads to the golden rule:
Put
"use client"as low in the tree as possible, on the small interactive leaves (a button, a search box), not on whole pages.
Mark a page-level component as client and you drag every component it imports into the bundle, throwing away the zero-JS benefit of server components for that entire page.
Where the directive goes
"use client" belongs at the very top of the file, before any imports. It is a file-level directive and must be the first statement, not a call you make inside a function.
From that point on, the file and everything it imports belongs to the client bundle. Putting it after the imports, or inside a component body, does not work: the build treats it as an ordinary string expression and the component stays on the server, so the hooks it uses will still fail.
Fixing a page that uses useState without the directive
Consider this file, which has no "use client" directive:
// app/newsletter/page.js import { useState } from "react"; export default function Newsletter() { const [email, setEmail] = useState(""); return <input value={email} onChange={(e) => setEmail(e.target.value)} />; }
One line fixes it: add "use client" as the first line of the file.
Why it works out that way
- Files in
app/are server components by default, anduseStateand event handlers likeonChangeonly work in client components. - The directive moves the file across the boundary, so hooks and events become legal.
In a real app there is a better fix available. Extract the input into a small client component of its own and keep the page itself on the server. That way the page can still fetch data directly and only the tiny input ships JavaScript, which is the low-in-the-tree rule from the previous block applied in practice.
needsUseClient: the decision as a lookup
The client/server decision is mechanical enough to write out in plain JavaScript, which is a useful way to fix the list of triggers in your memory. A function needsUseClient(features) receives an array of strings describing what a component uses, like ["useState", "props"], and returns true when any of them is client-only.
The client-only triggers are useState, useEffect, onClick, onChange, localStorage, and window. Everything else, including props, fetch, and async, is perfectly fine on the server.
const CLIENT_ONLY = ["useState", "useEffect", "onClick", "onChange", "localStorage", "window"]; function needsUseClient(features) { return features.some((f) => CLIENT_ONLY.includes(f)); } console.log(needsUseClient(["useState", "props"])); console.log(needsUseClient(["fetch", "props"])); console.log(needsUseClient(["onClick"]));
Output
true false true
Reading the implementation
Array.prototype.some(fn)returnstrueas soon asfnreturns true for at least one element, so a single client-only feature is enough to flip the answer.CLIENT_ONLY.includes(f)is the membership test, and combining it withsomeasks the real question: does this component use any browser-only capability.- The two groups map onto the two lists cleanly: state and event handlers need a live browser, while
fetchandpropsdo not.