<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: viswas saripalli</title>
    <description>The latest articles on DEV Community by viswas saripalli (@viswas_saripalli).</description>
    <link>https://dev.to/viswas_saripalli</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3849675%2Fe76d9f6e-2974-4b48-9693-90c65b468ca0.jpg</url>
      <title>DEV Community: viswas saripalli</title>
      <link>https://dev.to/viswas_saripalli</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/viswas_saripalli"/>
    <language>en</language>
    <item>
      <title>AI Fluency Series · Project 1: Streaming Chat Or: how I spent a day proving a bug I was causing myself.</title>
      <dc:creator>viswas saripalli</dc:creator>
      <pubDate>Sun, 12 Jul 2026 13:35:06 +0000</pubDate>
      <link>https://dev.to/viswas_saripalli/ai-fluency-series-project-1-streaming-chat-or-how-i-spent-a-day-proving-a-bug-i-was-causing-33on</link>
      <guid>https://dev.to/viswas_saripalli/ai-fluency-series-project-1-streaming-chat-or-how-i-spent-a-day-proving-a-bug-i-was-causing-33on</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; a dumb little start to leveling up my AI fluency. Nothing fancy, no grand architecture, no hot takes — just a journal of me trying to actually &lt;em&gt;understand&lt;/em&gt; things instead of pretending to.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Quick filter: if you're on React 19, have &lt;em&gt;actually enabled&lt;/em&gt; the compiler (it's an opt-in plugin, not something the version hands you for free), and you write pure components — close the tab, you already know the ending. If you're on React 19 and &lt;em&gt;assumed&lt;/em&gt; that meant the compiler was on: stay. That assumption is a trap, and I fell in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why chat is Project 1
&lt;/h2&gt;

&lt;p&gt;Streaming tokens into a growing list that re-renders on every one is the substrate under every AI feature I'm about to build — RAG answers stream, agents stream, all of it. If I can't make token-by-token rendering behave, nothing downstream will.&lt;/p&gt;

&lt;p&gt;So the rule I gave myself: &lt;strong&gt;build it by hand before reaching for the library.&lt;/strong&gt; No &lt;code&gt;useChat&lt;/code&gt; until I've felt the problem it solves. I did it in Next.js — partly because that's where I'm headed, partly as an excuse to learn it — but none of this is Next-specific; any framework does the same thing. And I mocked the stream instead of calling a real model, because the subject is how the &lt;em&gt;UI&lt;/em&gt; handles a stream, not the model. A real LLM would just be a slower way to make the same tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The storm
&lt;/h2&gt;

&lt;p&gt;The setup was deliberately dumb: append each token into one blob of state, map the whole array to the screen.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;setChunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;              &lt;span class="c1"&gt;// new array, every token&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)}&lt;/span&gt;    &lt;span class="c1"&gt;// whole list, every token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wired it up, fired the request, watched the words stream in. Worked on the first try — which felt great for about ten seconds, until the &lt;em&gt;first real problem&lt;/em&gt; surfaced: it re-rendered the entire message list on every single token. That's the O(N²) trap in the flesh. Token 200 re-processes 200 things, token 400 re-processes 400. It's not slow because it's badly written — it's slow because it recomputes the settled past on every update.&lt;/p&gt;

&lt;p&gt;I also added an input box to test typing mid-stream and found a &lt;em&gt;second&lt;/em&gt; bug I'd built for free: the input's state lived in the same component as the list, so every keystroke re-rendered all 400 messages. Two unrelated things welded together because they shared a component. Fix: move the input's state into its own component. Suddenly typing re-renders one input, not the whole conversation — the single most useful React habit, and it has nothing to do with streaming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then measuring got weird
&lt;/h2&gt;

&lt;p&gt;I wanted numbers, not vibes, so I dropped a render counter and a &lt;code&gt;console.log&lt;/code&gt; into the message component. Chunk 0 was rendering on &lt;em&gt;every&lt;/em&gt; token — the full storm, right there in the console. The Profiler said the same thing in pictures: record one response and watch the whole &lt;code&gt;.map&lt;/code&gt; re-run, every chunk lighting up in the flamegraph on every word.&lt;/p&gt;

&lt;p&gt;Except the numbers wouldn't sit still. Dev said 872 renders; prod said 426 — React's Strict Mode double-invokes in dev, so half my "data" was a lie the dev server was telling me. (Lesson one: never trust a perf number from &lt;code&gt;npm run dev&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;There was also a version goose-chase that ended in pure comedy:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;TypeError: messages.some is not a function&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
🧠 "typo?" → 🧠🧠 "&lt;code&gt;@ai-sdk/react&lt;/code&gt; is behind &lt;code&gt;ai&lt;/code&gt;, upgrade React!" → 🧠🌌 "the whole SDK protocol changed, I must rewrite everything"&lt;br&gt;
🤡 the actual fix: I forgot to type &lt;code&gt;await&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the real head-scratcher: I &lt;em&gt;knew&lt;/em&gt; the compiler was supposed to memoize my components — I'd enabled it, and DevTools showed the little ✨ badge. So why was chunk 0 still re-rendering every token?&lt;/p&gt;

&lt;h2&gt;
  
  
  The twist
&lt;/h2&gt;

&lt;p&gt;Here's the thing that made the whole day worth it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My measurement was causing the bug.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The React Compiler only memoizes components it can prove are &lt;em&gt;pure&lt;/em&gt; — same inputs, same output, no side effects during render. And I'd shoved a &lt;code&gt;console.log&lt;/code&gt; and a ref mutation straight into the render body to measure the renders. Those are side effects. So the compiler took one look, decided it couldn't safely optimize the component, and silently bailed — no warning, no error, it just quietly stopped memoizing.&lt;/p&gt;

&lt;p&gt;Which means the O(N²) storm I was so carefully measuring was, in part, &lt;em&gt;manufactured by the act of measuring it.&lt;/em&gt; The probe changed the experiment. I was shining a flashlight into a dark room to see how dark it was, then writing down "huh, pretty bright in here."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// impure → compiler refuses to memoize → every Chunk re-renders&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// ⛔ mutation in render&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;render&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// ⛔ side effect in render&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// pure → compiler memoizes it; measure from an effect instead&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;committed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull the side effects out, gate the logging into a &lt;code&gt;useEffect&lt;/code&gt;, and chunk 0 went quiet. The ✨ badges meant what they said. The storm collapsed to O(N) — and I hadn't written a single &lt;code&gt;React.memo&lt;/code&gt;. The compiler did it, the moment I stopped poisoning the well.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Full honesty, since this is a journal and not a press release: the airtight proof is a clean Profiler read on a late commit with zero instrumentation. I eyeballed the sparkles more than I formally captured that. I'm ~95% there; the last 5% is a thirty-second measurement I owe myself.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I used the library
&lt;/h2&gt;

&lt;p&gt;Only after all that did I swap in &lt;code&gt;useChat&lt;/code&gt; — and building it by hand first meant I could see exactly what it does. The fetch, the reader, the accumulation, the state updates: the entire machine I'd hand-cranked, gone, replaced by three destructured values.&lt;/p&gt;

&lt;p&gt;But the surprise, which probably shouldn't have been one: &lt;strong&gt;&lt;code&gt;useChat&lt;/code&gt; doesn't fix the render cost.&lt;/strong&gt; It owns &lt;em&gt;when&lt;/em&gt; you render — every token, via its own state updates — and has zero opinion about how expensive each render is. Drop a &lt;code&gt;console.log&lt;/code&gt; back into a message component and the O(N²) is right back, &lt;code&gt;useChat&lt;/code&gt; and all. The abstraction changed the plumbing. It did not change the physics.&lt;/p&gt;

&lt;h2&gt;
  
  
  What carried over
&lt;/h2&gt;

&lt;p&gt;The React-specific trivia is fun, but the two keepers are bigger than React:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Re-processing the settled past on every update is the cost that bites.&lt;/strong&gt; Streaming chat, an append-only log, a game loop — same shape, same fix: isolate what changed from what didn't. Memoization is just React's dialect for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your instrument can change what you measure.&lt;/strong&gt; I hit it three times in one day — dev double-renders, keystrokes polluting my counts, a &lt;code&gt;console.log&lt;/code&gt; disabling the compiler — and I anchored on an exciting theory (version hell!) while the stack trace pointed at the boring truth the whole time. "Did my measurement perturb the thing?" is now a reflex, and it's a &lt;em&gt;science&lt;/em&gt; habit, not a frontend one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 2026 punchline, and the reason for that snarky opener: you mostly don't &lt;em&gt;do&lt;/em&gt; memoization anymore. Keep your components pure and let the compiler do it. The skill moved from "know where to sprinkle &lt;code&gt;memo&lt;/code&gt;" to "write pure components — and know how to tell when the compiler quietly gave up on you."&lt;/p&gt;

&lt;p&gt;Next up in the series: RAG. Where, I'm told, the actual AI begins.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;— filed from a laptop that is now, finally, rendering only what changed.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>learning</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How We Handled 100K Items in a React List</title>
      <dc:creator>viswas saripalli</dc:creator>
      <pubDate>Sun, 29 Mar 2026 16:40:28 +0000</pubDate>
      <link>https://dev.to/viswas_saripalli/scaling-react-list-to-100k-items-466o</link>
      <guid>https://dev.to/viswas_saripalli/scaling-react-list-to-100k-items-466o</guid>
      <description>&lt;p&gt;Imagine you're building a bulk configuration UI. Users connect to a data source — a Postgres database, a SaaS API, a data warehouse — and need to select which items to include: tables, fields, events, records. Dozens is easy. A few hundred is manageable. But what happens when the list hits 100,000?&lt;/p&gt;

&lt;p&gt;That's the situation I ran into. This post is about the approach we landed on, why it works, and how to apply it anywhere you have a large server-side list with sparse user edits.&lt;/p&gt;




&lt;h2&gt;
  
  
  The naive approach and why it breaks
&lt;/h2&gt;

&lt;p&gt;The instinctive approach is to fetch the list, copy it into local state, and update entries in place as the user interacts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Naive approach — don't do this at scale&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setItems&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;apiResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;  &lt;span class="c1"&gt;// copy everything&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toggleItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setItems&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;       &lt;span class="c1"&gt;// O(n) on every toggle&lt;/span&gt;
    &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSelected&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;
  &lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works fine at small scale. At 100K items, you have three compounding problems:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Startup cost.&lt;/strong&gt; Copying 100K objects into state on mount takes meaningful time and memory. If some items start excluded, you often need an initialization loop to pre-populate a separate "excluded" set — another O(n) pass before the user sees anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interaction cost.&lt;/strong&gt; Every checkbox toggle triggers a state update that React has to reconcile. Even with virtualization, the state update itself touches the full array.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Downstream cost.&lt;/strong&gt; Any feature that needs to know "what changed" — a review panel, a diff summary, a submission payload — has to scan the full list to find the delta. O(n) on every toggle.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The root problem is the implicit assumption that "UI state = copy of server data." Once you make that assumption, everything downstream inherits the O(n) cost.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  A better approach: store only what changed
&lt;/h2&gt;

&lt;p&gt;The better mental model: the server response is immutable ground truth. Your UI state only records the &lt;em&gt;delta&lt;/em&gt; — what the user changed from that baseline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server response (immutable base)
  items[]: { id, name, isSelected, ... }
        │
        ▼
Delta state (user changes only)
  selectedIds   = Set&amp;lt;id&amp;gt;   ← user explicitly selected
  deselectedIds = Set&amp;lt;id&amp;gt;   ← user explicitly deselected
  allSelected   = boolean    ← "Select All" clicked
  noneSelected  = boolean    ← "Deselect All" clicked
        │
        ▼
Resolution layer (derived on demand)
  isSelected(id) → O(1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The delta state starts empty. &lt;code&gt;selectedIds&lt;/code&gt; and &lt;code&gt;deselectedIds&lt;/code&gt; only grow when the user actually interacts. An item the user never touches has no entry in either set.&lt;/p&gt;

&lt;p&gt;The resolution layer answers "is this item selected?" by checking the delta first, then falling back to the server base data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Bulk operations take priority&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;noneSelected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allSelected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Individual overrides&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// No user change — fall back to server data directly&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;itemsById&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;isSelected&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is what makes everything else possible. Because the resolution layer can consult the original server data, &lt;strong&gt;the delta state never needs to be pre-populated&lt;/strong&gt;. Start with empty sets, and checkboxes render correctly on first paint — no initialization loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  No hydration: eliminating the startup cost
&lt;/h2&gt;

&lt;p&gt;A common pattern when loading a list with some pre-excluded items is to hydrate the exclusion set on mount:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Common pattern — unnecessary with this approach&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;excluded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;apiItems&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setExcludedIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;excluded&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// O(n) before user sees anything&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With delta state, this loop is unnecessary. The sets start empty. &lt;code&gt;isSelected()&lt;/code&gt; falls through to &lt;code&gt;item.isSelected&lt;/code&gt; from the server response for any item the user hasn't touched. The UI is correct from the very first render.&lt;/p&gt;

&lt;p&gt;At 100K items, eliminating this loop measurably reduces time-to-interactive.&lt;/p&gt;




&lt;h2&gt;
  
  
  Toggling: O(1) state updates
&lt;/h2&gt;

&lt;p&gt;Checkbox toggles now only update the delta sets — a Set add or delete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toggleItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setDelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// revert to server default&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;// revert to server default&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// was selected → deselect&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// was deselected → select&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The state object is tiny regardless of total item count. React reconciliation only touches components that consume the delta — not components that render individual items, because those only call &lt;code&gt;isSelected(id)&lt;/code&gt; and get a stable result.&lt;/p&gt;




&lt;h2&gt;
  
  
  O(k) review and diff — for free
&lt;/h2&gt;

&lt;p&gt;Here's where this approach pays compound dividends. Any feature that needs to know "what changed" just iterates the delta sets — not the full item list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useReviewChanges&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;added&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allSelected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Newly added = server had isSelected:false, not in deselectedIds&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;allItems&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSelected&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
          &lt;span class="nx"&gt;added&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Normal case — iterate only the delta sets&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;itemsById&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;added&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;itemsById&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;added&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;removed&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;em&gt;k&lt;/em&gt; is the size of the user's change sets — typically single digits in a real session. A live "review changes" panel powered by this hook updates instantly on every toggle, regardless of total item count.&lt;/p&gt;

&lt;p&gt;The same principle applies to the submission payload — only send what changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generatePayload&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selectedIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;itemsById&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deselectedIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;itemsById&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;isSelected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 3 entries, not 100K&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The key insight:&lt;/strong&gt; By designing state as "what changed from the server" rather than "copy of server data", every downstream operation — review panel, submission payload, undo history — inherits O(k) complexity automatically. You don't optimize each feature separately; the data structure does it for you.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Moving index-building off the main thread
&lt;/h2&gt;

&lt;p&gt;Even with lean delta state, you still need to build a lookup index from the raw API response: a &lt;code&gt;Map&amp;lt;id, item&amp;gt;&lt;/code&gt; for O(1) access, relationship maps for parent-child hierarchies, a flat list for rendering. At 100K items, doing this synchronously on the main thread causes a noticeable freeze.&lt;/p&gt;

&lt;p&gt;Move it to a Web Worker. But be careful about what you send — &lt;code&gt;postMessage&lt;/code&gt; uses structured clone, and serializing full item objects is expensive:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Items&lt;/th&gt;
&lt;th&gt;Full objects (~500 bytes each)&lt;/th&gt;
&lt;th&gt;Minimal projection (~80 bytes)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100K&lt;/td&gt;
&lt;td&gt;~50 MB&lt;/td&gt;
&lt;td&gt;~8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500K&lt;/td&gt;
&lt;td&gt;~250 MB&lt;/td&gt;
&lt;td&gt;~40 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1M&lt;/td&gt;
&lt;td&gt;~500 MB&lt;/td&gt;
&lt;td&gt;~80 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Send the worker only the fields it actually needs for indexing — typically just IDs and relationship fields. The worker builds the structural index and sends back serialized maps of IDs. The main thread reconstructs full lookup structures from its own data reference, avoiding a large clone in the return direction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only send what the worker needs for indexing&lt;/span&gt;
&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;postMessage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BUILD_INDEX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;groupKey&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;groupKey&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;   &lt;span class="c1"&gt;// ~80 bytes vs ~500 bytes&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Worker returns ID maps only — main thread rebuilds full objects&lt;/span&gt;
&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INDEX_READY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;deserializeIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same worker can handle search: send a query string, receive back an array of matching IDs. The main thread resolves full items from its local index. Search never blocks the render cycle until results are ready.&lt;/p&gt;




&lt;h2&gt;
  
  
  One gotcha: be careful with useDeferredValue
&lt;/h2&gt;

&lt;p&gt;If your list has cascade behavior — selecting a parent automatically selects its children — you may be tempted to wrap the resolution layer in &lt;code&gt;useDeferredValue&lt;/code&gt; to avoid re-renders on rapid clicks. We tried this. It introduced a subtle correctness bug.&lt;/p&gt;

&lt;p&gt;Cascade effects that work by falling through to &lt;code&gt;isSelected()&lt;/code&gt; (rather than explicitly adding children to the delta sets) depend on the resolution layer being current. With a deferred (stale) resolution layer, the review panel called &lt;code&gt;isSelected()&lt;/code&gt; on an old snapshot and reported incorrect counts for cascaded children until the deferred update caught up.&lt;/p&gt;

&lt;p&gt;The fix was to remove &lt;code&gt;useDeferredValue&lt;/code&gt; entirely. The resolution layer is just closures over small Sets — recomputing it is cheap. &lt;strong&gt;Profile before deferring. Stale derived state has correctness implications that are easy to miss.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where else this applies
&lt;/h2&gt;

&lt;p&gt;This approach is useful anywhere you have a large server-side collection with sparse user edits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permission managers&lt;/strong&gt; — a list of users or roles where an admin toggles access. The server has the current state; the delta tracks only the changes before save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spreadsheet-style editors&lt;/strong&gt; — a large grid where users edit individual cells. Store only the edited cells as a delta on the original data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk tag/label editors&lt;/strong&gt; — applying or removing labels from a large item list. The delta records only touched items.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature flag consoles&lt;/strong&gt; — enabling or disabling flags across a large service registry. Flags start at server defaults; the delta captures the diff before deployment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In each case, the same invariant holds: the server data is ground truth, the delta captures user intent, and the resolution layer derives the current state on demand.&lt;/p&gt;




&lt;h2&gt;
  
  
  The takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Server data is immutable ground truth.&lt;/strong&gt; Never copy it into mutable local state. Store only the delta — what the user changed — as lightweight Sets or Maps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never hydrate if you can fall back.&lt;/strong&gt; If your resolution layer can consult the original server data, the change sets don't need to be pre-populated. Start empty and let the fallback handle the initial render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design state around access patterns.&lt;/strong&gt; Use Sets for O(1) membership checks. Use Maps for O(1) ID lookup. The right data structure makes every downstream operation trivially fast without explicit optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Send workers only what they need.&lt;/strong&gt; Structured clone is not free. Profile your &lt;code&gt;postMessage&lt;/code&gt; payload — it's easy to accidentally serialize far more than necessary. A minimal projection costs seconds of engineering and pays off at every scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Be cautious with deferred values near cascade logic.&lt;/strong&gt; &lt;code&gt;useDeferredValue&lt;/code&gt; and &lt;code&gt;useTransition&lt;/code&gt; create a window where derived state is stale. If other logic depends on that state being current, you'll get subtle bugs that are hard to reproduce.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This approach scales cleanly from hundreds to millions of items. The same core — immutable base, lightweight delta, on-demand resolution — works whether you're building a permission manager, a bulk config form, or a spreadsheet editor.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>performance</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
