my blog https://overreacted.io

Impossible Components

+1611 -7
+14 -4
app/[slug]/markdown.css
··· 31 31 @apply mt-2 pb-8 text-xl font-bold; 32 32 } 33 33 34 - .markdown :is(h1, h2, h3, h4, h5) a:is(:hover, :focus, :active)::before, 35 - .markdown :is(h1, h2, h3, h4, h5):is(:target, :focus) a::before { 34 + .markdown :is(h1, h2, h3, h4) a:is(:hover, :focus, :active)::before, 35 + .markdown :is(h1, h2, h3, h4):is(:target, :focus) a::before { 36 36 content: "#"; 37 37 position: absolute; 38 38 transform: translate(-1em); ··· 79 79 margin-bottom: 0; 80 80 } 81 81 82 - .markdown ul { 82 + .markdown ul:not(.unstyled) { 83 83 @apply list-inside md:list-outside list-disc; 84 84 margin-top: 0; 85 85 padding-bottom: 0; ··· 90 90 list-style-image: none; 91 91 } 92 92 93 - .markdown li { 93 + .markdown li:not(.unstyled) { 94 94 margin-bottom: calc(1.75rem / 2); 95 95 } 96 96 ··· 106 106 107 107 .markdown ol { 108 108 @apply mb-8 list-inside md:list-outside list-decimal; 109 + } 110 + 111 + .markdown input { 112 + color: #222; 113 + } 114 + 115 + @media (prefers-color-scheme: dark) { 116 + .markdown input { 117 + color: #111; 118 + } 109 119 } 110 120 111 121 .markdown pre [data-highlighted-line] {
+4 -1
app/posts.js
··· 3 3 import { Feed } from "feed"; 4 4 5 5 export const metadata = { 6 - title: "overreacted", 6 + title: "overreacted — A blog by Dan Abramov", 7 7 description: "A blog by Dan Abramov", 8 + openGraph: { 9 + title: "overreacted", 10 + }, 8 11 alternates: { 9 12 types: { 10 13 "application/atom+xml": "https://overreacted.io/atom.xml",
+183
public/impossible-components/client.js
··· 1 + "use client"; 2 + 3 + import { Fragment, useState } from "react"; 4 + 5 + export function GreetingFrontend({ color }) { 6 + const [yourName, setYourName] = useState("Alice"); 7 + return ( 8 + <div className="font-sans text-xl"> 9 + <input 10 + className="border-2 mb-1 p-1 rounded-lg" 11 + placeholder="What's your name?" 12 + value={yourName} 13 + onChange={(e) => setYourName(e.target.value)} 14 + /> 15 + <p className="font-semibold" style={{ color }}> 16 + Hello, {yourName}! 17 + </p> 18 + </div> 19 + ); 20 + } 21 + 22 + export function GreetingFrontend_2({ color }) { 23 + const [yourName, setYourName] = useState("Alice"); 24 + return ( 25 + <div className="font-sans text-xl"> 26 + <input 27 + className="border-2 mb-1 p-1 rounded-lg" 28 + placeholder="What's your name?" 29 + value={yourName} 30 + onChange={(e) => setYourName(e.target.value)} 31 + onFocus={() => { 32 + document.body.style.backgroundColor = color; 33 + }} 34 + onBlur={() => { 35 + document.body.style.backgroundColor = ""; 36 + }} 37 + /> 38 + <p className="font-semibold">Hello, {yourName}!</p> 39 + </div> 40 + ); 41 + } 42 + 43 + export function SortableList({ items }) { 44 + const [isReversed, setIsReversed] = useState(false); 45 + const sortedItems = isReversed ? items.toReversed() : items; 46 + return ( 47 + <div className="font-sans text-sm"> 48 + <button 49 + className="mb-4 font-semibold text-md border-2 px-4 py-2 rounded-md bg-purple-500 hover:opacity-95 hover:scale-105 transform text-white" 50 + onClick={() => setIsReversed(!isReversed)} 51 + > 52 + Flip order 53 + </button> 54 + <ul className="ml-4"> 55 + {sortedItems.map((item) => ( 56 + <li key={item}>{item}</li> 57 + ))} 58 + </ul> 59 + </div> 60 + ); 61 + } 62 + 63 + export function SortableList_2({ items }) { 64 + const [isReversed, setIsReversed] = useState(false); 65 + const [filterText, setFilterText] = useState(""); 66 + let filteredItems = items; 67 + if (filterText !== "") { 68 + filteredItems = items.filter((item) => 69 + item.toLowerCase().startsWith(filterText.toLowerCase()), 70 + ); 71 + } 72 + const sortedItems = isReversed ? filteredItems.toReversed() : filteredItems; 73 + return ( 74 + <div className="font-sans text-sm"> 75 + <button 76 + className="mb-4 font-semibold text-md border-2 px-4 py-2 rounded-md bg-purple-500 hover:opacity-95 hover:scale-105 transform text-white" 77 + onClick={() => setIsReversed(!isReversed)} 78 + > 79 + Flip order 80 + </button> 81 + <input 82 + value={filterText} 83 + onChange={(e) => setFilterText(e.target.value)} 84 + placeholder="Search..." 85 + className="block border-2 px-1 mb-4" 86 + /> 87 + <ul className="ml-4"> 88 + {sortedItems.map((item) => ( 89 + <li key={item}>{item}</li> 90 + ))} 91 + </ul> 92 + </div> 93 + ); 94 + } 95 + 96 + export function ExpandingSection({ children }) { 97 + return <section className="rounded-md bg-black/5 p-2">{children}</section>; 98 + } 99 + 100 + export function ExpandingSection_2({ children, extraContent }) { 101 + const [isExpanded, setIsExpanded] = useState(false); 102 + return ( 103 + <section 104 + className="rounded-md bg-black/5 p-2" 105 + onClick={() => setIsExpanded(!isExpanded)} 106 + > 107 + {children} 108 + {isExpanded && extraContent} 109 + </section> 110 + ); 111 + } 112 + 113 + export function SortableList_3({ items }) { 114 + const [isReversed, setIsReversed] = useState(false); 115 + const [filterText, setFilterText] = useState(""); 116 + let filteredItems = items; 117 + if (filterText !== "") { 118 + filteredItems = items.filter((item) => 119 + item.searchText.toLowerCase().startsWith(filterText.toLowerCase()), 120 + ); 121 + } 122 + const sortedItems = isReversed ? filteredItems.toReversed() : filteredItems; 123 + return ( 124 + <div className="font-sans text-sm"> 125 + <> 126 + <button 127 + className="mb-4 font-semibold text-md border-2 px-4 py-2 rounded-md bg-purple-500 hover:opacity-95 hover:scale-105 transform text-white" 128 + onClick={() => setIsReversed(!isReversed)} 129 + > 130 + Flip order 131 + </button> 132 + <input 133 + value={filterText} 134 + onChange={(e) => setFilterText(e.target.value)} 135 + placeholder="Search..." 136 + className="block border-2 px-1 mb-4" 137 + /> 138 + </> 139 + <ul> 140 + {sortedItems.map((item) => ( 141 + <li key={item.id}>{item.content}</li> 142 + ))} 143 + </ul> 144 + </div> 145 + ); 146 + } 147 + 148 + export function SortableList_4({ items }) { 149 + const [isReversed, setIsReversed] = useState(false); 150 + const [filterText, setFilterText] = useState(""); 151 + let filteredItems = items; 152 + if (filterText !== "") { 153 + filteredItems = items.filter((item) => 154 + item.searchText.toLowerCase().startsWith(filterText.toLowerCase()), 155 + ); 156 + } 157 + const sortedItems = isReversed ? filteredItems.toReversed() : filteredItems; 158 + return ( 159 + <> 160 + <div className="font-sans text-sm"> 161 + <button 162 + className="mb-4 font-semibold text-md border-2 px-4 py-2 rounded-md bg-purple-500 hover:opacity-95 hover:scale-105 transform text-white" 163 + onClick={() => setIsReversed(!isReversed)} 164 + > 165 + Flip order 166 + </button> 167 + <input 168 + value={filterText} 169 + onChange={(e) => setFilterText(e.target.value)} 170 + placeholder="Search..." 171 + className="block border-2 px-1 mb-4" 172 + /> 173 + </div> 174 + <ul className="unstyled gap-2 flex flex-col"> 175 + {sortedItems.map((item) => ( 176 + <li className="unstyled" key={item.id}> 177 + {item.content} 178 + </li> 179 + ))} 180 + </ul> 181 + </> 182 + ); 183 + }
+1
public/impossible-components/color.txt
··· 1 + dodgerblue
+1
public/impossible-components/color1.txt
··· 1 + crimson
+1
public/impossible-components/color2.txt
··· 1 + deeppink
+1
public/impossible-components/color3.txt
··· 1 + deepskyblue
+3
public/impossible-components/components.js
··· 1 + export * from "../react-for-two-computers/components"; 2 + export * from "./server"; 3 + export * from "./client";
+1251
public/impossible-components/index.md
··· 1 + --- 2 + title: Impossible Components 3 + date: '2025-04-22' 4 + spoiler: Composing across the stack. 5 + --- 6 + 7 + Suppose I want to greet you in *my* favorite color. 8 + 9 + This would require combining information from two different computers. Your name would be coming from *your* computer. The color would be on *my* computer. 10 + 11 + You could imagine a component that does this: 12 + 13 + ```js {5-6} 14 + import { useState } from 'react'; 15 + import { readFile } from 'fs/promises'; 16 + 17 + async function ImpossibleGreeting() { 18 + const [yourName, setYourName] = useState('Alice'); 19 + const myColor = await readFile('./color.txt', 'utf8'); 20 + return ( 21 + <> 22 + <input placeholder="What's your name?" 23 + value={yourName} 24 + onChange={e => setYourName(e.target.value)} 25 + /> 26 + <p style={{ color: myColor }}> 27 + Hello, {yourName}! 28 + </p> 29 + </> 30 + ); 31 + } 32 + ``` 33 + 34 + But this component is impossible. The `readFile` function can only execute on *my* computer. The `useState` will only have a useful value on *your* computer. We can't do both at once without giving up the predictable top-down execution flow. 35 + 36 + Or can we? 37 + 38 + --- 39 + 40 + ### Splitting a Component 41 + 42 + Let's split this component in two parts. 43 + 44 + The *first* part will read the file, which only makes sense on *my* computer. It is responsible for loading data so we're going to call this part `GreetingBackend`: 45 + 46 + <Server> 47 + 48 + ```js {4} 49 + import { readFile } from 'fs/promises'; 50 + import { GreetingFrontend } from './client'; 51 + 52 + async function GreetingBackend() { 53 + const myColor = await readFile('./color.txt', 'utf8'); 54 + return <GreetingFrontend color={myColor} />; 55 + } 56 + ``` 57 + 58 + </Server> 59 + 60 + It will read my chosen color and pass it as the `color` prop to the second part, which is responsible for interactivity. We're going to call it `GreetingFrontend`: 61 + 62 + <Client> 63 + 64 + ```js {5} 65 + 'use client'; 66 + 67 + import { useState } from 'react'; 68 + 69 + export function GreetingFrontend({ color }) { 70 + const [yourName, setYourName] = useState('Alice'); 71 + return ( 72 + <> 73 + <input placeholder="What's your name?" 74 + value={yourName} 75 + onChange={e => setYourName(e.target.value)} 76 + /> 77 + <p style={{ color }}> 78 + Hello, {yourName}! 79 + </p> 80 + </> 81 + ); 82 + } 83 + ``` 84 + 85 + </Client> 86 + 87 + That second part receives that `color`, and returns an interactive form. Edit "Alice" to say your name and notice how the greeting updates as you're typing: 88 + 89 + ```js eval 90 + <div className="mx-2"> 91 + <GreetingBackend /> 92 + </div> 93 + ``` 94 + 95 + (If your name *is* Alice, you may leave it as is.) 96 + 97 + **Notice that *the backend runs first.* Our mental model here isn't "frontend loads data from the backend". Rather, it's "the backend passes data *to* the frontend".** 98 + 99 + This is React's top-down data flow, but including the backend *into* the flow. The backend is the source of truth for the data--so it [must be](https://react.dev/learn/thinking-in-react#step-4-identify-where-your-state-should-live) the frontend's *parent*. 100 + 101 + Have another look at these two parts and see how the data flows *down:* 102 + 103 + <Server> 104 + 105 + ```js 106 + import { readFile } from 'fs/promises'; 107 + import { GreetingFrontend } from './client'; 108 + 109 + async function GreetingBackend() { 110 + const myColor = await readFile('./color.txt', 'utf8'); 111 + return <GreetingFrontend color={myColor} />; 112 + } 113 + ``` 114 + 115 + </Server> 116 + 117 + <Client glued> 118 + 119 + ```js 120 + 'use client'; 121 + 122 + import { useState } from 'react'; 123 + 124 + export function GreetingFrontend({ color }) { 125 + const [yourName, setYourName] = useState('Alice'); 126 + return ( 127 + <> 128 + <input placeholder="What's your name?" 129 + value={yourName} 130 + onChange={e => setYourName(e.target.value)} 131 + /> 132 + <p style={{ color }}> 133 + Hello, {yourName}! 134 + </p> 135 + </> 136 + ); 137 + } 138 + ``` 139 + 140 + </Client> 141 + 142 + From the backend to the frontend. From my computer to yours. 143 + 144 + Together, they form a single, *encapsulated* abstraction spanning both worlds: 145 + 146 + ```js 147 + <GreetingBackend /> 148 + ``` 149 + 150 + ```js eval 151 + <div className="mx-2"> 152 + <GreetingBackend /> 153 + </div> 154 + ``` 155 + 156 + 157 + Together, they form an impossible component. 158 + 159 + *(Here and below, the `'use client'` syntax hints that we'll be learning React Server Components. You can try them in [Next](https://nextjs.org/)--or in [Parcel](https://parceljs.org/recipes/rsc/) if you don't want a framework.)* 160 + 161 + --- 162 + 163 + ### Local State, Local Data 164 + 165 + The beautiful thing about this pattern is that I can refer to the *entirety* of this functionality--*its both sides*--by writing a JSX tag just for the "backend" part. Since the backend *renders* the frontend, rendering the backend gives you both. 166 + 167 + To demonstrate this, let's render `<GreetingBackend>` multiple times: 168 + 169 + ```js 170 + <> 171 + <GreetingBackend /> 172 + <GreetingBackend /> 173 + <GreetingBackend /> 174 + </> 175 + ``` 176 + 177 + ```js eval 178 + <div className="mx-2"> 179 + <GreetingBackend /> 180 + <GreetingBackend /> 181 + <GreetingBackend /> 182 + </div> 183 + ``` 184 + 185 + Verify that you can edit each input independently. 186 + 187 + Naturally, the `GreetingFrontend` *state* inside of each `GreetingBackend` is isolated. However, how each `GreetingBackend` *loads its data* is also isolated. 188 + 189 + To demonstrate this, let's edit `GreetingBackend` to take a `colorFile` prop: 190 + 191 + <Server> 192 + 193 + ```js {4-5} 194 + import { readFile } from 'fs/promises'; 195 + import { GreetingFrontend } from './client'; 196 + 197 + async function GreetingBackend({ colorFile }) { 198 + const myColor = await readFile(colorFile, 'utf8'); 199 + return <GreetingFrontend color={myColor} />; 200 + } 201 + ``` 202 + 203 + </Server> 204 + 205 + Next, let's add `Welcome` that renders `GreetingBackend` for different color files: 206 + 207 + <Server> 208 + 209 + ```js {4-12} 210 + import { readFile } from 'fs/promises'; 211 + import { GreetingFrontend } from './client'; 212 + 213 + function Welcome() { 214 + return ( 215 + <> 216 + <GreetingBackend colorFile="./color1.txt" /> 217 + <GreetingBackend colorFile="./color2.txt" /> 218 + <GreetingBackend colorFile="./color3.txt" /> 219 + </> 220 + ); 221 + } 222 + 223 + async function GreetingBackend({ colorFile }) { 224 + const myColor = await readFile(colorFile, 'utf8'); 225 + return <GreetingFrontend color={myColor} />; 226 + } 227 + ``` 228 + 229 + </Server> 230 + 231 + Let's see what happens: 232 + 233 + ```js 234 + <Welcome /> 235 + ``` 236 + 237 + Each greeting will read its own file. And each input will be independently editable. 238 + 239 + ```js eval 240 + <div className="mx-2"> 241 + <Welcome /> 242 + </div> 243 + ``` 244 + 245 + This might remind you of composing "server partials" in Rails or Django, except that instead of HTML you're rendering fully interactive React component trees. 246 + 247 + Now you can see the whole deal: 248 + 249 + 1. **Each `GreetingBackend` *knows* how to load its own data.** That logic is encapsulated in `GreetingBackend`--you didn't need to coordinate them. 250 + 2. **Each `GreetingFrontend` *knows* how to manage its own state.** That logic is encapsulated in `GreetingFrontend`--again, no manual coordination. 251 + 3. **Each `GreetingBackend` renders a `GreetingFrontend`.** This lets you think of `GreetingBackend` as a self-contained unit that does *both*--an impossible component. It's a piece of the backend *with its own* piece of the frontend. 252 + 253 + Of course, you can substitute "reading files" with "querying an ORM", "talking to an LLM with a secret API key", "hitting an internal microservice", or anything that requires backend-only resources. Likewise, an "input" represents any interactivity. The point is that you can compose both sides into self-contained components. 254 + 255 + Let's render `Welcome` again: 256 + 257 + ```js 258 + <Welcome /> 259 + ``` 260 + 261 + ```js eval 262 + <div className="mx-2"> 263 + <Welcome /> 264 + </div> 265 + ``` 266 + 267 + Notice how we didn't need to plumb any data or state into it. 268 + 269 + The `<Welcome />` tag is completely self-contained! 270 + 271 + And because the backend parts always *run first*, when you load this page, from the frontend's perspective, the data is "already there". There are no flashes of "loading data from the backend"--the backend *has already passed* the data to the frontend. 272 + 273 + Local state. 274 + 275 + Local data. 276 + 277 + Single roundtrip. 278 + 279 + *Self-contained.* 280 + 281 + --- 282 + 283 + ### It's Not About HTML 284 + 285 + Okay, but how is this different from just rendering a bunch of HTML? 286 + 287 + Let's tweak the `GreetingFrontend` component to do something different: 288 + 289 + <Server> 290 + 291 + ```js 292 + import { readFile } from 'fs/promises'; 293 + import { GreetingFrontend } from './client'; 294 + 295 + async function GreetingBackend() { 296 + const myColor = await readFile('./color.txt', 'utf8'); 297 + return <GreetingFrontend color={myColor} />; 298 + } 299 + ``` 300 + 301 + </Server> 302 + 303 + <Client glued> 304 + 305 + ```js {12-17,19} 306 + 'use client'; 307 + 308 + import { useState } from 'react'; 309 + 310 + export function GreetingFrontend({ color }) { 311 + const [yourName, setYourName] = useState('Alice'); 312 + return ( 313 + <> 314 + <input placeholder="What's your name?" 315 + value={yourName} 316 + onChange={e => setYourName(e.target.value)} 317 + onFocus={() => { 318 + document.body.style.backgroundColor = color; 319 + }} 320 + onBlur={() => { 321 + document.body.style.backgroundColor = ''; 322 + }} 323 + /> 324 + <p> 325 + Hello, {yourName}! 326 + </p> 327 + </> 328 + ); 329 + } 330 + ``` 331 + 332 + </Client> 333 + 334 + Instead of styling `<p>`, we'll set `document.body.style.backgroundColor` to the `color` from the backend--but only for as long as the input is focused. 335 + 336 + Try typing into the input: 337 + 338 + ```js eval 339 + <div className="mx-2"> 340 + <GreetingBackend_2 /> 341 + </div> 342 + ``` 343 + 344 + Depending on how you look at it, the fact that this "just works" can seem either completely natural, or a total surprise, or a bit of both. The backend is passing props to the frontend, but *not for the purpose of generating the initial HTML markup.* 345 + 346 + The props are being used *later*--in order to "do something" in the event handler. 347 + 348 + <Client> 349 + 350 + ```js {5,12} 351 + 'use client'; 352 + 353 + import { useState } from 'react'; 354 + 355 + export function GreetingFrontend({ color }) { 356 + // ... 357 + return ( 358 + <> 359 + <input placeholder="What's your name?" 360 + // ... 361 + onFocus={() => { 362 + document.body.style.backgroundColor = color; 363 + }} 364 + // ... 365 + /> 366 + ... 367 + </> 368 + ); 369 + } 370 + ``` 371 + 372 + </Client> 373 + 374 + Of course, we're not limited to passing colors. We could pass strings, numbers, booleans, objects, pieces of JSX--anything that can be sent over the wire. 375 + 376 + Now let's try rendering `<Welcome />` again which composes our components: 377 + 378 + ```js {4-12} 379 + import { readFile } from 'fs/promises'; 380 + import { GreetingFrontend } from './client'; 381 + 382 + function Welcome() { 383 + return ( 384 + <> 385 + <GreetingBackend colorFile="./color1.txt" /> 386 + <GreetingBackend colorFile="./color2.txt" /> 387 + <GreetingBackend colorFile="./color3.txt" /> 388 + </> 389 + ); 390 + } 391 + 392 + async function GreetingBackend({ colorFile }) { 393 + const myColor = await readFile(colorFile, 'utf8'); 394 + return <GreetingFrontend color={myColor} />; 395 + } 396 + ``` 397 + 398 + Notice how each greeting now has the new behavior but remains independent: 399 + 400 + ```js eval 401 + <Welcome_2 /> 402 + ``` 403 + 404 + Local data, local state. 405 + 406 + Nothing conflicts with each other. No global identifiers, no naming clashes. Any component can be reused anywhere in the tree and will remain self-contained. 407 + 408 + *Local, therefore composable.* 409 + 410 + Now that you get the idea, let's have some fun with it. 411 + 412 + --- 413 + 414 + ### A Sortable List 415 + 416 + Imagine another *impossible* component--a sortable file list. 417 + 418 + ```js 419 + import { useState } from 'react'; 420 + import { readdir } from 'fs/promises'; 421 + 422 + async function SortableFileList({ directory }) { 423 + const [isReversed, setIsReversed] = useState(false); 424 + const files = await readdir(directory); 425 + const sortedFiles = isReversed ? files.toReversed() : files; 426 + return ( 427 + <> 428 + <button onClick={() => setIsReversed(!isReversed)}> 429 + Flip order 430 + </button> 431 + <ul> 432 + {sortedFiles.map(file => 433 + <li key={file}> 434 + {file} 435 + </li> 436 + )} 437 + </ul> 438 + </> 439 + ); 440 + } 441 + ``` 442 + 443 + Of course, this doesn't make sense. The information `readdir` needs only exists on *my* computer but the sorting order you choose with `useState` lives on *your* computer. (The most I *could* do on mine is to prepare HTML for the initial state.) 444 + 445 + How do we fix this component? 446 + 447 + By now, you know the drill: 448 + 449 + <Server> 450 + 451 + ```js 452 + import { SortableList } from './client'; 453 + import { readdir } from 'fs/promises'; 454 + 455 + async function SortableFileList({ directory }) { 456 + const files = await readdir(directory); 457 + return <SortableList items={files} />; 458 + } 459 + ``` 460 + 461 + </Server> 462 + 463 + <Client glued> 464 + 465 + ```js 466 + 'use client'; 467 + 468 + import { useState } from 'react'; 469 + 470 + export function SortableList({ items }) { 471 + const [isReversed, setIsReversed] = useState(false); 472 + const sortedItems = isReversed ? items.toReversed() : items; 473 + return ( 474 + <> 475 + <button onClick={() => setIsReversed(!isReversed)}> 476 + Flip order 477 + </button> 478 + <ul> 479 + {sortedItems.map(item => ( 480 + <li key={item}> 481 + {item} 482 + </li> 483 + ))} 484 + </ul> 485 + </> 486 + ); 487 + } 488 + ``` 489 + 490 + </Client> 491 + 492 + Let's try it: 493 + 494 + ```js 495 + <SortableFileList directory="." /> 496 + ``` 497 + 498 + ```js eval 499 + <SortableFileList directory="./public/impossible-components" /> 500 + ``` 501 + 502 + So far so good. Now notice that the `items` being passed down is an array. We're already using that to conditionally reverse it. What else could we do with an array? 503 + 504 + --- 505 + 506 + ### A Filterable List 507 + 508 + It would be nice if we could filter the list of files with an input. Filtering must happen on *your* machine (the most I could do on *mine* is to generate HTML for the initial state). Therefore, it makes sense to add the filter logic to the frontend part: 509 + 510 + <Server> 511 + 512 + ```js 513 + import { SortableList } from './client'; 514 + import { readdir } from 'fs/promises'; 515 + 516 + async function SortableFileList({ directory }) { 517 + const files = await readdir(directory); 518 + return <SortableList items={files} />; 519 + } 520 + ``` 521 + 522 + </Server> 523 + 524 + <Client glued> 525 + 526 + ```js {7-14,20-24} 527 + 'use client'; 528 + 529 + import { useState } from 'react'; 530 + 531 + export function SortableList({ items }) { 532 + const [isReversed, setIsReversed] = useState(false); 533 + const [filterText, setFilterText] = useState(''); 534 + let filteredItems = items; 535 + if (filterText !== '') { 536 + filteredItems = items.filter(item => 537 + item.toLowerCase().startsWith(filterText.toLowerCase()) 538 + ); 539 + } 540 + const sortedItems = isReversed ? filteredItems.toReversed() : filteredItems; 541 + return ( 542 + <> 543 + <button onClick={() => setIsReversed(!isReversed)}> 544 + Flip order 545 + </button> 546 + <input 547 + value={filterText} 548 + onChange={(e) => setFilterText(e.target.value)} 549 + placeholder="Search..." 550 + /> 551 + <ul> 552 + {sortedItems.map(item => ( 553 + <li key={item}>{item}</li> 554 + ))} 555 + </ul> 556 + </> 557 + ); 558 + } 559 + ``` 560 + 561 + </Client> 562 + 563 + Notice how the backend part only executes once--since my blog is static, it runs during deployment. But the frontend logic is reactive to your every keystroke: 564 + 565 + ```js eval 566 + <SortableFileList_2 directory="./public/impossible-components" /> 567 + ``` 568 + 569 + And because it's a reusable component, I can point it at some other data source: 570 + 571 + ```js 572 + <SortableFileList directory="./node_modules/react/" /> 573 + ``` 574 + 575 + ```js eval 576 + <SortableFileList_2 directory="./node_modules/react/" /> 577 + ``` 578 + 579 + What we've got here is, again, a self-contained component that can load its own data on the backend and hand it off to the frontend for client-side interactivity. 580 + 581 + Let's see how far we can push this. 582 + 583 + --- 584 + 585 + ### An Expanding Preview 586 + 587 + Here's a little `PostPreview` component for my blog: 588 + 589 + <Server> 590 + 591 + ```js 592 + import { readFile } from 'fs/promises'; 593 + import matter from 'gray-matter'; 594 + 595 + async function PostPreview({ slug }) { 596 + const fileContent = await readFile('./public/' + slug + '/index.md', 'utf8'); 597 + const { data, content } = matter(fileContent); 598 + const wordCount = content.split(' ').filter(Boolean).length; 599 + 600 + return ( 601 + <section className="rounded-md bg-black/5 p-2"> 602 + <h5 className="font-bold"> 603 + <a href={'/' + slug} target="_blank"> 604 + {data.title} 605 + </a> 606 + </h5> 607 + <i>{wordCount.toLocaleString()} words</i> 608 + </section> 609 + ); 610 + } 611 + ``` 612 + 613 + </Server> 614 + 615 + It looks like this: 616 + 617 + ```js 618 + <PostPreview slug="jsx-over-the-wire" /> 619 + ``` 620 + 621 + ```js eval 622 + <div className="mb-8"> 623 + <PostPreview slug="jsx-over-the-wire" /> 624 + </div> 625 + ``` 626 + 627 + Isn't it neat how it loads its own data? (Or rather, how the data is *already there*?) 628 + 629 + Now let's say I want to add a little interaction to it. For example, let's say that I want the card to expand on click so that it displays the first sentence of the post. 630 + 631 + Getting the first sentence on the backend is pretty easy: 632 + 633 + <Server> 634 + 635 + ```js {5-6,16} 636 + async function PostPreview({ slug }) { 637 + const fileContent = await readFile('./public/' + slug + '/index.md', 'utf8'); 638 + const { data, content } = matter(fileContent); 639 + const wordCount = content.split(' ').filter(Boolean).length; 640 + const firstSentence = content.split('.')[0]; 641 + const isExpanded = true; // TODO: Somehow connect this to clicking 642 + 643 + return ( 644 + <section className="rounded-md bg-black/5 p-2"> 645 + <h5 className="font-bold"> 646 + <a href={'/' + slug} target="_blank"> 647 + {data.title} 648 + </a> 649 + </h5> 650 + <i>{wordCount.toLocaleString()} words</i> 651 + {isExpanded && <p>{firstSentence} [...]</p>} 652 + </section> 653 + ); 654 + } 655 + ``` 656 + 657 + </Server> 658 + 659 + ```js eval 660 + <div className="mb-8"> 661 + <PostPreview_2 slug="jsx-over-the-wire" /> 662 + </div> 663 + ``` 664 + 665 + But how do we expand it *on click?* A *click* is a frontend concept, and so is state in general. Let's extract a frontend component that I'll call an `ExpandingSection`: 666 + 667 + <Server> 668 + 669 + ```js {3,13,21} 670 + import { readFile } from 'fs/promises'; 671 + import matter from 'gray-matter'; 672 + import { ExpandingSection } from './client'; 673 + 674 + async function PostPreview({ slug }) { 675 + const fileContent = await readFile('./public/' + slug + '/index.md', 'utf8'); 676 + const { data, content } = matter(fileContent); 677 + const wordCount = content.split(' ').filter(Boolean).length; 678 + const firstSentence = content.split('.')[0]; 679 + const isExpanded = true; // TODO: Somehow connect this to clicking 680 + 681 + return ( 682 + <ExpandingSection> 683 + <h5 className="font-bold"> 684 + <a href={'/' + slug} target="_blank"> 685 + {data.title} 686 + </a> 687 + </h5> 688 + <i>{wordCount.toLocaleString()} words</i> 689 + {isExpanded && <p>{firstSentence} [...]</p>} 690 + </ExpandingSection> 691 + ); 692 + } 693 + ``` 694 + 695 + </Server> 696 + 697 + <Client glued> 698 + 699 + ```js {3-9} 700 + 'use client'; 701 + 702 + export function ExpandingSection({ children }) { 703 + return ( 704 + <section className="rounded-md bg-black/5 p-2"> 705 + {children} 706 + </section> 707 + ); 708 + } 709 + ``` 710 + 711 + </Client> 712 + 713 + By itself, this doesn't change anything--it just moves the `<section>` from the world of data (the backend) to the world of state and event handlers (the frontend). 714 + 715 + But now that we're *on* the frontend, we can start layering the interaction logic: 716 + 717 + <Client> 718 + 719 + ```js {5-6,10,13} 720 + 'use client'; 721 + 722 + import { useState } from 'react'; 723 + 724 + export function ExpandingSection({ children, extraContent }) { 725 + const [isExpanded, setIsExpanded] = useState(false); 726 + return ( 727 + <section 728 + className="rounded-md bg-black/5 p-2" 729 + onClick={() => setIsExpanded(!isExpanded)} 730 + > 731 + {children} 732 + {isExpanded && extraContent} 733 + </section> 734 + ); 735 + } 736 + ``` 737 + 738 + </Client> 739 + 740 + *(Note that in a real app, you'd need to make the press target a button and avoid nesting the link inside to stay accessible. I'm skimming over it for clarity but you shouldn't.)* 741 + 742 + Let's verify that `ExpandingSection` works as expected. Try clicking "Hello": 743 + 744 + ```js 745 + <ExpandingSection 746 + extraContent={<p>World</p>} 747 + > 748 + <p>Hello</p> 749 + </ExpandingSection> 750 + ``` 751 + 752 + ```jsx eval 753 + <div className="mb-8"> 754 + <ExpandingSection_2 extraContent={<p style={{padding: 0}}>World</p>}> 755 + <p style={{padding: 0}}>Hello</p> 756 + </ExpandingSection_2> 757 + </div> 758 + ``` 759 + 760 + Now we have an `<ExpandingSection>` that toggles showing its `extraContent` on click. All that's left to do is to pass that `extraContent` *from the backend:* 761 + 762 + <Server> 763 + 764 + ```js {7} 765 + async function PostPreview({ slug }) { 766 + // ... 767 + const firstSentence = content.split('.')[0]; 768 + 769 + return ( 770 + <ExpandingSection 771 + extraContent={<p>{firstSentence} [...]</p>} 772 + > 773 + ... 774 + </ExpandingSection> 775 + ); 776 + } 777 + ``` 778 + 779 + </Server> 780 + 781 + <Client glued> 782 + 783 + ```js {13} 784 + 'use client'; 785 + 786 + import { useState } from 'react'; 787 + 788 + export function ExpandingSection({ children, extraContent }) { 789 + const [isExpanded, setIsExpanded] = useState(false); 790 + return ( 791 + <section 792 + className="rounded-md bg-black/5 p-2" 793 + onClick={() => setIsExpanded(!isExpanded)} 794 + > 795 + {children} 796 + {isExpanded && extraContent} 797 + </section> 798 + ); 799 + } 800 + ``` 801 + 802 + </Client> 803 + 804 + Let's try this again: 805 + 806 + ```js 807 + <PostPreview slug="jsx-over-the-wire" /> 808 + ``` 809 + 810 + The component's *initial* state looks exactly like before. But try clicking the card: 811 + 812 + ```js eval 813 + <div className="mb-8 transition-[height]"> 814 + <PostPreview_3 slug="jsx-over-the-wire" /> 815 + </div> 816 + ``` 817 + 818 + Now the extra content shows up! Notice there aren't any requests being made as you're toggling the card--the `extraContent` prop was *already there*. Here's the full code so you can trace the props flow down from the backend to the frontend: 819 + 820 + <Server> 821 + 822 + ```js {13,15-20} 823 + import { readFile } from 'fs/promises'; 824 + import matter from 'gray-matter'; 825 + import { ExpandingSection } from './client'; 826 + 827 + async function PostPreview({ slug }) { 828 + const fileContent = await readFile('./public/' + slug + '/index.md', 'utf8'); 829 + const { data, content } = matter(fileContent); 830 + const wordCount = content.split(' ').filter(Boolean).length; 831 + const firstSentence = content.split('.')[0]; 832 + 833 + return ( 834 + <ExpandingSection 835 + extraContent={<p>{firstSentence} [...]</p>} 836 + > 837 + <h5 className="font-bold"> 838 + <a href={'/' + slug} target="_blank"> 839 + {data.title} 840 + </a> 841 + </h5> 842 + <i>{wordCount.toLocaleString()} words</i> 843 + </ExpandingSection> 844 + ); 845 + } 846 + ``` 847 + 848 + </Server> 849 + 850 + <Client glued> 851 + 852 + ```js {5,12-13} 853 + 'use client'; 854 + 855 + import { useState } from 'react'; 856 + 857 + export function ExpandingSection({ children, extraContent }) { 858 + const [isExpanded, setIsExpanded] = useState(false); 859 + return ( 860 + <section 861 + className="rounded-md bg-black/5 p-2" 862 + onClick={() => setIsExpanded(!isExpanded)} 863 + > 864 + {children} 865 + {isExpanded && extraContent} 866 + </section> 867 + ); 868 + } 869 + ``` 870 + 871 + </Client> 872 + 873 + The props always flow down. 874 + 875 + Note it was important to place `ExpandingSection` into the frontend world, i.e. the file with `'use client'`. The backend doesn't have a *concept* of state--it starts fresh on every request--so importing `useState` there would be a build error. 876 + 877 + However, you can always take a tag like `<section>...</section>` and replace it with a frontend component like `<ExpandedSection>...</ExpandedSection>` that enriches a plain `<section>` with some stateful logic and event handlers. 878 + 879 + This might remind you of weaving. You've left `children` and `extraContent` as "holes" in `<ExpandedSection>...</ExpandedSection>`, and then you've "filled in" those holes with more content *from* the backend. You'll see this a lot because it's the only way to nest more backend stuff *inside* the frontend stuff. 880 + 881 + Get used to it! 882 + 883 + --- 884 + 885 + ### A List of Previews 886 + 887 + Let me add a new `PostList` component that renders an array of `PostPreview`s. 888 + 889 + <Server> 890 + 891 + ```js {4-14} 892 + import { readFile, readdir } from 'fs/promises'; 893 + import matter from 'gray-matter'; 894 + 895 + async function PostList() { 896 + const entries = await readdir('./public/', { withFileTypes: true }); 897 + const dirs = entries.filter(entry => entry.isDirectory()); 898 + return ( 899 + <div className="mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans"> 900 + {dirs.map(dir => ( 901 + <PostPreview key={dir.name} slug={dir.name} /> 902 + ))} 903 + </div> 904 + ); 905 + } 906 + 907 + async function PostPreview({ slug }) { 908 + // ... 909 + } 910 + ``` 911 + 912 + </Server> 913 + 914 + It also needs to live on the backend since it uses the filesystem `readdir` API. 915 + 916 + Here it is, showing a list of all posts on my blog: 917 + 918 + ```js 919 + <PostList /> 920 + ``` 921 + 922 + ```js eval 923 + <PostList /> 924 + ``` 925 + 926 + Notice how you can click each card, and it will expand. This is not plain HTML--all of these are interactive React components that got their props from the backend. 927 + 928 + --- 929 + 930 + ### A Sortable List of Previews 931 + 932 + Now let's make the list of previews filterable and sortable. 933 + 934 + Here's what we want to end up with: 935 + 936 + ```js eval 937 + <SortablePostList /> 938 + ``` 939 + 940 + How hard could it be? 941 + 942 + First, let's dig up the `SortableList` component from earlier. We're going to take the same exact [code as before](#a-filterable-list) but we'll assume `items` to be an array of objects shaped like `{ id, content, searchText }` rather than an array of strings: 943 + 944 + <Client> 945 + 946 + ```js {11,27-29} 947 + 'use client'; 948 + 949 + import { useState } from 'react'; 950 + 951 + export function SortableList({ items }) { 952 + const [isReversed, setIsReversed] = useState(false); 953 + const [filterText, setFilterText] = useState(''); 954 + let filteredItems = items; 955 + if (filterText !== '') { 956 + filteredItems = items.filter(item => 957 + item.searchText.toLowerCase().startsWith(filterText.toLowerCase()), 958 + ); 959 + } 960 + const sortedItems = isReversed ? filteredItems.toReversed() : filteredItems; 961 + return ( 962 + <> 963 + <button onClick={() => setIsReversed(!isReversed)}> 964 + Flip order 965 + </button> 966 + <input 967 + value={filterText} 968 + onChange={(e) => setFilterText(e.target.value)} 969 + placeholder="Search..." 970 + /> 971 + <ul> 972 + {sortedItems.map(item => ( 973 + <li key={item.id}> 974 + {item.content} 975 + </li> 976 + ))} 977 + </ul> 978 + </> 979 + ); 980 + } 981 + ``` 982 + 983 + </Client> 984 + 985 + For `SortableFileList`, we'll keep passing the filename itself as each field: 986 + 987 + <Server> 988 + 989 + ```js {6-11} 990 + import { SortableList } from './client'; 991 + import { readdir } from 'fs/promises'; 992 + 993 + async function SortableFileList({ directory }) { 994 + const files = await readdir(directory); 995 + const items = files.map((file) => ({ 996 + id: file, 997 + content: file, 998 + searchText: file, 999 + })); 1000 + return <SortableList items={items} />; 1001 + } 1002 + ``` 1003 + 1004 + </Server> 1005 + 1006 + <Client glued> 1007 + 1008 + ```js 1009 + 'use client'; 1010 + 1011 + import { useState } from 'react'; 1012 + 1013 + export function SortableList({ items }) { 1014 + // ... 1015 + } 1016 + ``` 1017 + 1018 + </Client> 1019 + 1020 + You can see that it continues working just fine: 1021 + 1022 + ```js 1023 + <SortableFileList directory="./public/impossible-components" /> 1024 + ``` 1025 + 1026 + ```js eval 1027 + <SortableFileList_3 directory="./public/impossible-components" /> 1028 + ``` 1029 + 1030 + However, now we can reuse `<SortableList>` by passing a list of posts to it: 1031 + 1032 + <Server> 1033 + 1034 + ```js {7-11,14} 1035 + import { SortableList } from './client'; 1036 + import { readdir } from 'fs/promises'; 1037 + 1038 + async function SortablePostList() { 1039 + const entries = await readdir('./public/', { withFileTypes: true }); 1040 + const dirs = entries.filter((entry) => entry.isDirectory()); 1041 + const items = dirs.map((dir) => ({ 1042 + id: dir.name, 1043 + searchText: dir.name.replaceAll('-', ' '), 1044 + content: <PostPreview slug={dir.name} /> 1045 + })); 1046 + return ( 1047 + <div className="mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans"> 1048 + <SortableList items={items} /> 1049 + </div> 1050 + ); 1051 + } 1052 + ``` 1053 + 1054 + </Server> 1055 + 1056 + <Client glued> 1057 + 1058 + ```js 1059 + 'use client'; 1060 + 1061 + import { useState } from 'react'; 1062 + 1063 + export function SortableList({ items }) { 1064 + // ... 1065 + } 1066 + ``` 1067 + 1068 + </Client> 1069 + 1070 + See for yourself: 1071 + 1072 + ```js 1073 + <SortablePostList /> 1074 + ``` 1075 + 1076 + ```js eval 1077 + <SortablePostList /> 1078 + ``` 1079 + 1080 + Play with the demo above and make sure you understand what's going on. 1081 + 1082 + This is a fully interactive React tree. You can click on individual items, and they will expand and collapse thanks to the local state inside `<ExpandingSection>`. In fact, if you expand a card, click "Flip order" and then "Flip order" again, you'll notice that the card stays expanded--it just moved down and back up in the tree. 1083 + 1084 + You can do the filtering and reordering thanks to `<SortableList>`. Note that the `SortableList` itself is blissfully unaware of *what* it's sorting. You can put a list of any content inside it--and it's fine to pass props to it directly from the backend. 1085 + 1086 + On the backend, the `<PostPreview>` component fully encapsulates reading information for a specific post. It takes care of counting the words and extracting their first sentence, and then passing that down to the `<ExpandingSection>`. 1087 + 1088 + Notice that although there is a single `<PostPreview>` rendered for each of my posts, the data necessary for *this entire page* is being collected in a single run and served as a single roundtrip. When you visit this page, there are no extra requests. Only the data *used by the UI* is sent over the wire--i.e. the props for the frontend. 1089 + 1090 + We're composing self-contained components that each can load their own data or manage their own state. At any point, we can add more encapsulated data loading logic or more encapsulated stateful logic at any point in the tree--as long as we're doing it in the right world. It takes some skill and practice to learn these patterns, but the reward is making components like `<SortablePostList />` possible. 1091 + 1092 + ```js eval 1093 + <SortablePostList /> 1094 + ``` 1095 + 1096 + Local state. 1097 + 1098 + Local data. 1099 + 1100 + Single roundtrip. 1101 + 1102 + *Self-contained.* 1103 + 1104 + --- 1105 + 1106 + ### In Conclusion 1107 + 1108 + Our users don't care about how any of this stuff works. When people use our websites and apps, they don't think in terms of the "frontend" and the "backend". They see the things on the screen: *a section, a header, a post preview, a sortable list.* 1109 + 1110 + *But maybe our users are right.* 1111 + 1112 + Composable abstractions with self-contained data logic and state logic let us speak the same language as our users. Component APIs like `<PostPreview slug="...">` and `<SortableList items={...}>` map to how we *intuitively* think about those boxes on the screen. The fact that implementing self-contained `<PostPreview>` and `<SortableList>` without compromises requires running them on different "sides" is not a problem if we can compose them together. 1113 + 1114 + The division between the frontend and the backend is physical. We can't escape from the fact that we're writing client/server applications. Some logic is naturally *more suited* to either side. But one side should not dominate the other. And we shouldn't have to change the approach whenever we need to move the boundary. 1115 + 1116 + What we need are the tools that let us *compose across the stack*. Then we can create self-contained LEGO blocks that run where appropriate--and snap them together. Any piece of UI can have its own backend needs *and* frontend needs. It's time that our tools acknowledge that, embrace that, and let us speak our users' language. 1117 + 1118 + --- 1119 + 1120 + ### Next Steps 1121 + 1122 + We've seen a few composition patterns but we've barely scratched the surface of what's possible. Some ideas if you want to play around with it on your own: 1123 + 1124 + - You can add more backend-only logic to `PostPreview`. For example, it might be nice to parse the first sentence from Markdown (but strip formatting). 1125 + - You can highlight the partial search match text in the individual `PostPreview` items. One way to do this would be to provide a `FilterTextContext` with a `filterText` value from the `SortableList`, and then extract `<h5>` from `PostPreview` into a frontend `PostHeader` that reads that Context. 1126 + - If you're happy making your project dynamically served per-request (rather than static like my blog), you can move the *filtering logic itself* to the backend by reading the `filterText` from the route query params. The `SortableList` component could be taught to cause a router navigation instead of setting local state, and to display a pending indicator while the screen is being refetched in-place. This is useful if you want to apply filtering to many more rows, e.g. from a database. 1127 + - Speaking of refetching, my blog is fully static--but if your app is dynamic, you could add a "Refresh" button that refreshes the list of posts without changing any of these components. Notably, refreshing *would not destroy the existing state*, so if you expanded any cards or edited the filter text, the newly added items matching the filter would gracefully appear in the list. You could even animate them in. 1128 + - Of course, if your app is dynamic, you can also add mutations and call the backend *from* the frontend via `'use server'`. This fits neatly into the same paradigm. 1129 + - Imagine your own impossible components! Maybe you'd like an `<Image>` that reads from the filesystem and creates a blur gradient placeholder by itself? Think about the last time you were writing a component and needed some information that's only known "on the backend" or "during the build". Now you have it. 1130 + 1131 + The most important thing, in my opinion, is to get a feel for self-contained data loading and stateful logic, and how to compose them together. Then you're good. 1132 + 1133 + --- 1134 + 1135 + ### A Note on Terminology 1136 + 1137 + As in my other [recent](https://overreacted.io/react-for-two-computers/) [articles](https://overreacted.io/jsx-over-the-wire/), I've tried to avoid using the "Server Components" and "Client Components" terminology in this post because it brings up distracting connotations and knee-jerk reactions. (In particular, people tend to assume the "client loads from the server" rather than the "server renders the client" model.) 1138 + 1139 + The "backend components" in this post are officially called Server Components, and the "frontend components" are officially called Client Components. If I could change the official terminology, I probably still would *not.* However, I find that introducing it when you already understand the model (as I hope you do by this point) works better than starting with the terminology. This may eventually stop being a problem if the Server/Client split as modeled by React Server Components ever becomes the generally accepted model of describing distributed composable user interfaces. I think we may get there at some point within the next ten years. 1140 + 1141 + --- 1142 + 1143 + ### Final Code 1144 + 1145 + Here's the complete code from the last example. 1146 + 1147 + <Server> 1148 + 1149 + ```js 1150 + import { SortableList, ExpandingSection } from './client'; 1151 + import { readdir, readFile } from 'fs/promises'; 1152 + 1153 + async function SortablePostList() { 1154 + const entries = await readdir('./public/', { withFileTypes: true }); 1155 + const dirs = entries.filter((entry) => entry.isDirectory()); 1156 + const items = dirs.map((dir) => ({ 1157 + id: dir.name, 1158 + searchText: dir.name.replaceAll('-', ' '), 1159 + content: <PostPreview slug={dir.name} />, 1160 + })); 1161 + return ( 1162 + <div className="mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans"> 1163 + <SortableList items={items} /> 1164 + </div> 1165 + ); 1166 + } 1167 + 1168 + async function PostPreview({ slug }) { 1169 + const fileContent = await readFile('./public/' + slug + '/index.md', "utf8"); 1170 + const { data, content } = matter(fileContent); 1171 + const wordCount = content.split(' ').filter(Boolean).length; 1172 + const firstSentence = content.split('.')[0]; 1173 + 1174 + return ( 1175 + <ExpandingSection 1176 + extraContent={<p>{firstSentence} [...]</p>} 1177 + > 1178 + <h5 className="font-bold"> 1179 + <a href={'/' + slug} target="_blank"> 1180 + {data.title} 1181 + </a> 1182 + </h5> 1183 + <i>{wordCount.toLocaleString()} words</i> 1184 + </ExpandingSection> 1185 + ); 1186 + } 1187 + ``` 1188 + 1189 + </Server> 1190 + 1191 + <Client glued> 1192 + 1193 + ```js 1194 + 'use client'; 1195 + 1196 + import { useState } from 'react'; 1197 + 1198 + export function SortableList({ items }) { 1199 + const [isReversed, setIsReversed] = useState(false); 1200 + const [filterText, setFilterText] = useState(''); 1201 + let filteredItems = items; 1202 + if (filterText !== '') { 1203 + filteredItems = items.filter(item => 1204 + item.searchText.toLowerCase().startsWith(filterText.toLowerCase()), 1205 + ); 1206 + } 1207 + const sortedItems = isReversed ? filteredItems.toReversed() : filteredItems; 1208 + return ( 1209 + <> 1210 + <button onClick={() => setIsReversed(!isReversed)}> 1211 + Flip order 1212 + </button> 1213 + <input 1214 + value={filterText} 1215 + onChange={(e) => setFilterText(e.target.value)} 1216 + placeholder="Search..." 1217 + /> 1218 + <ul> 1219 + {sortedItems.map(item => ( 1220 + <li key={item.id}> 1221 + {item.content} 1222 + </li> 1223 + ))} 1224 + </ul> 1225 + </> 1226 + ); 1227 + } 1228 + 1229 + export function ExpandingSection({ children, extraContent }) { 1230 + const [isExpanded, setIsExpanded] = useState(false); 1231 + return ( 1232 + <section 1233 + className="rounded-md bg-black/5 p-2" 1234 + onClick={() => setIsExpanded(!isExpanded)} 1235 + > 1236 + {children} 1237 + {isExpanded && extraContent} 1238 + </section> 1239 + ); 1240 + } 1241 + ``` 1242 + 1243 + </Client> 1244 + 1245 + ```js eval 1246 + <SortablePostList /> 1247 + ``` 1248 + 1249 + You can try this code in [Next](https://nextjs.org/)--or in [Parcel](https://parceljs.org/recipes/rsc/) if you don't want a framework. If you set up a full project from this code, feel free to send a pull request and I'll link it. 1250 + 1251 + Have fun!
+150
public/impossible-components/server.js
··· 1 + import { readFile, readdir } from "fs/promises"; 2 + import matter from "gray-matter"; 3 + import { 4 + GreetingFrontend, 5 + GreetingFrontend_2, 6 + SortableList, 7 + SortableList_2, 8 + SortableList_3, 9 + SortableList_4, 10 + ExpandingSection_2, 11 + } from "./client"; 12 + 13 + export async function GreetingBackend({ 14 + colorFile = "./public/impossible-components/color.txt", 15 + }) { 16 + const myColor = await readFile(colorFile, "utf8"); 17 + return <GreetingFrontend color={myColor} />; 18 + } 19 + 20 + export function Welcome() { 21 + return ( 22 + <> 23 + <GreetingBackend colorFile="./public/impossible-components/color1.txt" /> 24 + <GreetingBackend colorFile="./public/impossible-components/color2.txt" /> 25 + <GreetingBackend colorFile="./public/impossible-components/color3.txt" /> 26 + </> 27 + ); 28 + } 29 + 30 + export async function GreetingBackend_2({ 31 + colorFile = "./public/impossible-components/color.txt", 32 + }) { 33 + const myColor = await readFile(colorFile, "utf8"); 34 + return <GreetingFrontend_2 color={myColor} />; 35 + } 36 + 37 + export function Welcome_2() { 38 + return ( 39 + <> 40 + <GreetingBackend_2 colorFile="./public/impossible-components/color1.txt" /> 41 + <GreetingBackend_2 colorFile="./public/impossible-components/color2.txt" /> 42 + <GreetingBackend_2 colorFile="./public/impossible-components/color3.txt" /> 43 + </> 44 + ); 45 + } 46 + 47 + export async function SortableFileList({ directory }) { 48 + const files = await readdir(directory); 49 + return <SortableList items={files} />; 50 + } 51 + 52 + export async function SortableFileList_2({ directory }) { 53 + const files = await readdir(directory); 54 + return <SortableList_2 items={files} />; 55 + } 56 + 57 + export async function PostPreview({ slug }) { 58 + const fileContent = await readFile("./public/" + slug + "/index.md", "utf8"); 59 + const { data, content } = matter(fileContent); 60 + const wordCount = content.split(" ").filter(Boolean).length; 61 + 62 + return ( 63 + <section className="rounded-md bg-black/5 p-2"> 64 + <h5 className="font-bold"> 65 + <a href={"/" + slug} target="_blank"> 66 + {data.title} 67 + </a> 68 + </h5> 69 + <i>{wordCount.toLocaleString()} words</i> 70 + </section> 71 + ); 72 + } 73 + 74 + export async function PostPreview_2({ slug }) { 75 + const fileContent = await readFile("./public/" + slug + "/index.md", "utf8"); 76 + const { data, content } = matter(fileContent); 77 + const wordCount = content.split(" ").filter(Boolean).length; 78 + const firstSentence = content.split(/\.|\n\n/)[0]; 79 + 80 + return ( 81 + <section className="rounded-md bg-black/5 p-2"> 82 + <h5 className="font-bold"> 83 + <a href={"/" + slug} target="_blank"> 84 + {data.title} 85 + </a> 86 + </h5> 87 + <i>{wordCount.toLocaleString()} words</i> 88 + <p style={{ marginTop: 20, padding: 0 }}>{firstSentence} [...]</p> 89 + </section> 90 + ); 91 + } 92 + 93 + export async function PostPreview_3({ slug }) { 94 + const fileContent = await readFile("./public/" + slug + "/index.md", "utf8"); 95 + const { data, content } = matter(fileContent); 96 + const wordCount = content.split(" ").filter(Boolean).length; 97 + const firstSentence = content.split(/\.\s|\n\n/)[0]; 98 + 99 + return ( 100 + <ExpandingSection_2 101 + extraContent={ 102 + <p style={{ marginTop: 20, padding: 0 }}>{firstSentence} [...]</p> 103 + } 104 + > 105 + <h5 className="font-bold"> 106 + <a href={"/" + slug} target="_blank"> 107 + {data.title} 108 + </a> 109 + </h5> 110 + <i>{wordCount.toLocaleString()} words</i> 111 + </ExpandingSection_2> 112 + ); 113 + } 114 + 115 + export async function PostList() { 116 + const entries = await readdir("./public/", { withFileTypes: true }); 117 + const dirs = entries.filter((entry) => entry.isDirectory()); 118 + return ( 119 + <div className="mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans"> 120 + {dirs.map((dir) => ( 121 + <PostPreview_3 key={dir.name} slug={dir.name} /> 122 + ))} 123 + </div> 124 + ); 125 + } 126 + 127 + export async function SortableFileList_3({ directory }) { 128 + const files = await readdir(directory); 129 + const items = files.map((file) => ({ 130 + id: file, 131 + content: file, 132 + searchText: file, 133 + })); 134 + return <SortableList_3 items={items} />; 135 + } 136 + 137 + export async function SortablePostList() { 138 + const entries = await readdir("./public/", { withFileTypes: true }); 139 + const dirs = entries.filter((entry) => entry.isDirectory()); 140 + const items = dirs.map((dir) => ({ 141 + id: dir.name, 142 + searchText: dir.name.replaceAll("-", " "), 143 + content: <PostPreview_3 slug={dir.name} />, 144 + })); 145 + return ( 146 + <div className="mb-8 flex h-72 flex-col gap-2 overflow-scroll font-sans"> 147 + <SortableList_4 items={items} /> 148 + </div> 149 + ); 150 + }
+1 -1
public/the-two-reacts/index.md
··· 104 104 {data.title} 105 105 </a> 106 106 </h5> 107 - <i>{wordCount} words</i> 107 + <i>{wordCount.toLocaleString()} words</i> 108 108 </section> 109 109 ); 110 110 }
+1 -1
public/the-two-reacts/post-preview.js
··· 13 13 {data.title} 14 14 </a> 15 15 </h5> 16 - <i>{wordCount} words</i> 16 + <i>{wordCount.toLocaleString()} words</i> 17 17 </section> 18 18 ); 19 19 }