<?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>Why my RAG retriever couldn't find a number it was quoted verbatim</title>
      <dc:creator>viswas saripalli</dc:creator>
      <pubDate>Sun, 19 Jul 2026 15:59:03 +0000</pubDate>
      <link>https://dev.to/viswas_saripalli/why-my-rag-retriever-couldnt-find-a-number-it-was-quoted-verbatim-19gh</link>
      <guid>https://dev.to/viswas_saripalli/why-my-rag-retriever-couldnt-find-a-number-it-was-quoted-verbatim-19gh</guid>
      <description>&lt;h3&gt;
  
  
  what is RAG?
&lt;/h3&gt;

&lt;p&gt;Retrieval Augmented Generation. RAG gives an LLM the specific info relevant to a prompt by retrieving it from your own documents first, then answering from those passages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Embedding
&lt;/h3&gt;

&lt;p&gt;Representing words, phrases, or images as vectors in a high-dimensional space.&lt;/p&gt;

&lt;h3&gt;
  
  
  what is a vector in high-dimensional space?
&lt;/h3&gt;

&lt;p&gt;The model gives 768 numbers per text. Each number = a coordinate. So every text = a point in 768-D space. Similar meaning → close, unrelated → far. Closeness ≈ meaning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chunking
&lt;/h3&gt;

&lt;p&gt;Breaking the material into smaller pieces, so each vector represents one focused passage instead of a whole document averaged out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fntpkipsmgq3gqhh91q7x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fntpkipsmgq3gqhh91q7x.png" alt=" " width="798" height="195"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5al8wz8lkrtfqzdtun0z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5al8wz8lkrtfqzdtun0z.png" alt=" " width="799" height="138"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Chunking strategies
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;recursive-character, semantic, page-level, LLM-based, size-based, late chunking&lt;/li&gt;
&lt;li&gt;the wrong strategy can open a large recall gap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;what is recall?&lt;/strong&gt; — did retrieval fetch the right chunk to pass to the LLM?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;what is recall@k?&lt;/strong&gt; — was the right chunk inside the top-k cutoff?&lt;/p&gt;

&lt;h3&gt;
  
  
  Let's start
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Took 4 con-call transcripts of a company (Company A) — chose transcripts because they're pure-text PDFs (no tables).&lt;/li&gt;
&lt;li&gt;Used a node script to extract contents, with cleanup to remove the cover pages.&lt;/li&gt;
&lt;li&gt;Chunked them into size 1000, overlap 200 → chunks span chars 0–1000, 800–1800 …&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;why overlap?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a hard cut falls at char 1000, mid-sentence, that sentence gets split: "...revenue for the" ends chunk 0, "quarter was ₹340 crore" starts chunk 1. Now neither chunk contains the complete fact. When you embed them, chunk 0's vector is about a dangling fragment, chunk 1's about another — and a query like "what was quarterly revenue" may match neither well, because the whole idea was severed. Overlap fixes this by making adjacent chunks share their edges.&lt;/p&gt;

&lt;h3&gt;
  
  
  Now let's choose an embedding model
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;ollama nomic-embed-text or mxbai-embed-large?&lt;/li&gt;
&lt;li&gt;nomic-embed-text — 274 MB, enough for 4 docs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Let's query — recall@5
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query: how much did revenue grow in Q2

[0.717] CompanyA_Q3_FY26#11 (Q3 FY26) ...total standalone revenue for the...
[0.708] CompanyA_Q2_FY26#15 (Q2 FY26) ...hand over the call to our CFO...
[0.703] CompanyA_Q4_FY26#32 (Q4 FY26) ...royalty income added to revenue... EBITDA margin is stable...
[0.703] CompanyA_Q2_FY26#23 (Q2 FY26) ...targeting 10% to 15% revenue growth for FY '26...
[0.699] CompanyA_Q4_FY26#10 (Q4 FY26) ...pleasure to discuss our performance as we close...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  what are 0.717, 0.708?
&lt;/h3&gt;

&lt;p&gt;Cosine similarity scores — how close the query embedding vector is to each chunk's embedding vector.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1.0 = identical direction, 0.0 = unrelated, -1.0 = opposite direction (won't see this with real text)&lt;/li&gt;
&lt;li&gt;0.717 means the chunk is closer to the query asked&lt;/li&gt;
&lt;li&gt;Don't read the absolute value as "72% relevant" — cosine isn't a percentage, and what's "high" depends on the model. With nomic on similar prose, ~0.7 is ordinary. What's diagnostic is the &lt;strong&gt;gap between hits&lt;/strong&gt;, and here it's flat — a genuine finding about why retrieval on near-identical earnings calls is hard.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note: asked Q2, top hit is Q3. The quarter token got ignored.&lt;/p&gt;

&lt;h3&gt;
  
  
  what if I ask an irrelevant question?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query: who will win fifa world cup

[0.563] CompanyA_Q2_FY26#2  ...brief overview of the past quarter and first half...
[0.562] CompanyA_Q1_FY26#2  ...open the floor for Q&amp;amp;A...
[0.558] CompanyA_Q2_FY26#43 ...how much can we expect to win? probable success ratio?...
[0.557] CompanyA_Q4_FY26#16 ...growing the business gradually... if we win this award...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Data retrieved but irrelevant. cosine ALWAYS returns k results — no "no match". Scores are lower (~0.56 vs ~0.71), so the intuitive fix: reject anything below 0.6 and say "I don't know". &lt;strong&gt;But that fix is wrong — see failure 1.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  From now, the actual problem starts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  failure 1 — entity-blindness (the threshold can't work)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query: What was Company B's revenue last quarter?

[0.758] CompanyA_Q4_FY26#10 (Q4 FY26) ...pleasure to discuss our performance...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Company B is NOT in the corpus (different company). Yet it scored &lt;strong&gt;0.758 — HIGHER than every real in-domain query&lt;/strong&gt; (max 0.717). The embedding locked onto "revenue" and ignored "Company B".&lt;/p&gt;

&lt;p&gt;→ So the 0.6 threshold idea is dead: any cutoff that admits real questions (≤0.717) also admits Company B (0.758). Pizza (0.55, fully unrelated) is rejected fine, but wrong-entity-right-domain sails through. &lt;strong&gt;Dense embeddings capture topic, not entity.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  failure 2 — quarter-blindness (fixed by metadata)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Query: PAT for Q3 was 75 crores PAT margin 7.7

[0.758] CompanyA_Q4_FY26#11 (Q4 FY26) ...EBITDA for Q4 FY '26 stood at INR183 crores... margin of 14.1%...
[0.736] CompanyA_Q1_FY26#45 (Q1 FY26) ...order book of around Rs. 10,300 crores...
[0.709] CompanyA_Q2_FY26#24 (Q2 FY26) ...margins around 11.5%. Second half should be similar...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quoted Q3 verbatim, top 3 are Q4/Q1/Q2 — the target quarter isn't even there. The quarter token is nearly ignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;fix: metadata filtering&lt;/strong&gt; — parse the quarter from the query and hard-filter chunks to that quarter before ranking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;after metadata filtering (Q3 only):
[0.631] CompanyA_Q3_FY26#34 ...early completion bonus...
[0.631] CompanyA_Q3_FY26#31 ...INR605 crores invested in HAM...
[0.607] CompanyA_Q3_FY26#33 ...sale of those projects...
[0.604] CompanyA_Q3_FY26#32 ...outstanding cash as of December...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All Q3 now. Cross-quarter confusion solved — the one clean win.&lt;/p&gt;

&lt;h3&gt;
  
  
  failure 3 — token-blindness (chunking can't fix it)
&lt;/h3&gt;

&lt;p&gt;Even after isolating PAT into its own sentence-chunk (literally "PAT for Q3 was 75 crores"), the query "what was PAT" STILL doesn't retrieve it. Verified via grep the figures were never split. So it's not chunking — the dense model won't connect the token "PAT" to a chunk containing it. Needs hybrid dense+sparse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fixes each finding points to:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Entity-blindness → metadata (company field) or sparse; no cosine threshold works.&lt;/li&gt;
&lt;li&gt;Quarter-blindness → metadata filtering (scope to Q3).&lt;/li&gt;
&lt;li&gt;Token-blindness → hybrid dense+sparse (match the literal token dense smears away).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cosine similarity is about retrieval, not answer accuracy
&lt;/h3&gt;

&lt;p&gt;A chunk can score 0.72 and still not contain the number you need. recall@k is stricter (is the known-correct chunk in top-k) but still a retrieval metric — not answer correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moved towards sentence chunking
&lt;/h2&gt;

&lt;p&gt;Questions used for the recall@k eval:&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="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;What was total standalone revenue in Q3 FY26?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;goldIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CompanyA_Q3_FY26#38&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;What was standalone EBITDA margin in Q3 FY26?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;goldIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CompanyA_Q3_FY26#38&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CompanyA_Q3_FY26#39&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;What was PAT for Q3 FY26?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;goldIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CompanyA_Q3_FY26#39&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;What revenue growth guidance did management give for FY26?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;goldIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CompanyA_Q2_FY26#81&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;CompanyA_Q2_FY26#82&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="c1"&gt;// out-of-domain: goldIds MUST stay empty (any hit = false positive)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Who will win the FIFA World Cup?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;goldIds&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="c1"&gt;// ~0.56, rejected by 0.6&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;What was Company B's revenue last quarter?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;goldIds&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="c1"&gt;// 0.758, HIGHER than in-domain&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Best pizza place in Mumbai?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;goldIds&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="c1"&gt;// ~0.55, no overlap&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;recall@1&lt;/th&gt;
&lt;th&gt;recall@5&lt;/th&gt;
&lt;th&gt;PAT&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fixed 1000/200&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;miss&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentence (3/1)&lt;/td&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;miss&lt;/td&gt;
&lt;td&gt;theme fragmentation hurt guidance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sentence chunking made recall@1 WORSE (0.75→0.50) and still didn't fix PAT — guidance fragmented across ~12 chunks. Strongly suggests the PAT failure is embedding-level, not chunk size. Next = hybrid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving back to fixed chunking(recall@k got worse, not better — 0.75→0.50)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  why did isolating PAT into a sentence chunk still miss?
&lt;/h3&gt;

&lt;p&gt;Chunking and retrieval are different stages. Sentence-chunking only changed what's IN the chunk — but PAT didn't fail because of chunk contents, it failed because nomic places the query vector ("what was PAT") and the chunk vector ("PAT was ₹75 crores") FAR APART in embedding space. Chunk boundaries don't move those points closer. So a cleaner chunk can't fix a query↔chunk distance problem — the failure lives in the embedding, not the chunking. That's what makes it a clean negative result: it ruled out chunking as the cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  So how does the search actually happen?
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2mjgrb4v43edtx5zaz9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy2mjgrb4v43edtx5zaz9.png" alt=" " width="799" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  how to fix the query → chunk embedding distance problem?
&lt;/h3&gt;

&lt;p&gt;The PAT chunk exists but the query "what was PAT" lands far from it in vector space. Two families of fix:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A. move the points closer&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;better embedding model (bge-m3 etc) — might place them closer, not guaranteed&lt;/li&gt;
&lt;li&gt;query expansion — rewrite the query to look like the target text before embedding&lt;/li&gt;
&lt;li&gt;HyDE — embed a fake answer instead of the question (answers sit closer to answers)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;B. stop relying on distance&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hybrid (dense + BM25 sparse) — match the literal token "PAT", ignores geometry&lt;/li&gt;
&lt;li&gt;metadata extraction — pull figures into fields, query structure not prose&lt;/li&gt;
&lt;li&gt;reranking — retrieve wide (top-30), re-score query+chunk together with a cross-encoder&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For my case: HyDE or hybrid most likely to fix PAT. Both buildable, no training data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;where does PAT chunk rank in a top-30 dense retrieval?&lt;/strong&gt; It ranks 26/30 (0.539) — in the net but near-bottom, below the 0.6 threshold. Too deep for reranking to be reliable → hybrid (BM25) or HyDE is the fix, since both lift it in the initial ranking rather than re-ordering a barely-retrieved net.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is HyDE?
&lt;/h3&gt;

&lt;p&gt;HyDE = Hypothetical Document Embeddings.&lt;/p&gt;

&lt;p&gt;The problem: a question and an answer are shaped differently. "What was PAT for Q3?" is a short interrogative. "PAT for Q3 was ₹75 crores with a margin of 7.7%" is a declarative statement full of specifics. The embedding model reads these as different kinds of text and places them far apart — even though one answers the other.&lt;/p&gt;

&lt;p&gt;HyDE's trick: don't search with the question. Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ask a cheap LLM to write a fake, made-up answer to the question — e.g. "Company A's PAT for Q3 FY26 was approximately 70 crores, with a margin of around 8%." The numbers don't have to be correct.&lt;/li&gt;
&lt;li&gt;Embed that fake answer (not the question).&lt;/li&gt;
&lt;li&gt;Search with the fake answer's vector.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fake answer is shaped like an answer, so it embeds close to the real answer chunk. The hallucinated numbers are thrown away — the real chunk it retrieves is what you use. Tradeoff: one extra LLM call per query, versus hybrid/BM25 which is pure math and instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing hybrid search
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt; I search my documents by meaning. Ask "what was PAT?" and it should find the sentence with the PAT number. But it didn't — the computer thought a question and its answer looked too "different" to match, even though the answer was right there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; meaning-search is fuzzy. Great at "these are about the same topic," bad at "these both contain the exact word PAT." The number sentence got lost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix — two searchers instead of one:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Searcher A (what I had):&lt;/strong&gt; matches by meaning. Good at topics, bad at exact words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Searcher B (BM25):&lt;/strong&gt; matches by exact words. Dumb but reliable — just checks "does this chunk literally contain 'PAT'?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run both, combine the answers. That combo is hybrid search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The twist:&lt;/strong&gt; combining them equally didn't work — the meaning-searcher was so confidently wrong about the PAT chunk that it dragged the right answer down, even though the word-searcher had it at #1. Fix: tell the combiner "trust the word-searcher more." That did it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;recall@1&lt;/th&gt;
&lt;th&gt;recall@3&lt;/th&gt;
&lt;th&gt;recall@5&lt;/th&gt;
&lt;th&gt;PAT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;meaning-only (dense)&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;❌ miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hybrid (meaning + words)&lt;/td&gt;
&lt;td&gt;0.75&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;1.00&lt;/td&gt;
&lt;td&gt;✅ found&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;PAT went from "not found" to "found." recall@3 jumped 0.75 → 1.00 — all four questions now find their answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  After adding the generation layer (llama3:8b)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsx answer.ts &lt;span class="s2"&gt;"What was PAT for Q3 FY26?"&lt;/span&gt;

Retrieved 5 chunks:
&lt;span class="o"&gt;[&lt;/span&gt;1] CompanyA_Q3_FY26#11
&lt;span class="o"&gt;[&lt;/span&gt;2] CompanyA_Q3_FY26#16
&lt;span class="o"&gt;[&lt;/span&gt;3] CompanyA_Q3_FY26#15
&lt;span class="o"&gt;[&lt;/span&gt;4] CompanyA_Q3_FY26#0
&lt;span class="o"&gt;[&lt;/span&gt;5] CompanyA_Q3_FY26#12

Q: What was PAT &lt;span class="k"&gt;for &lt;/span&gt;Q3 FY26?
A: The answer can be found &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;1]:
   &lt;span class="s2"&gt;"While our standalone PAT for Q3 FY 26 was INR75 crores with a PAT margin of 7.7%."&lt;/span&gt;
   Therefore, the answer is: INR75 crores
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsx answer.ts &lt;span class="s2"&gt;"What was PAT for Q3 FY26 of Company B?"&lt;/span&gt;

Retrieved 5 chunks:
&lt;span class="o"&gt;[&lt;/span&gt;1] CompanyA_Q3_FY26#11
&lt;span class="o"&gt;[&lt;/span&gt;2] CompanyA_Q3_FY26#16
&lt;span class="o"&gt;[&lt;/span&gt;3] CompanyA_Q3_FY26#12
&lt;span class="o"&gt;[&lt;/span&gt;4] CompanyA_Q3_FY26#34
&lt;span class="o"&gt;[&lt;/span&gt;5] CompanyA_Q3_FY26#15

Q: What was PAT &lt;span class="k"&gt;for &lt;/span&gt;Q3 FY26 of Company B?
A: Not found &lt;span class="k"&gt;in &lt;/span&gt;the provided documents. The context only mentions PAT figures
   &lt;span class="k"&gt;for &lt;/span&gt;Company A Limited, but not &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="s2"&gt;"Company B"&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  "not found" is the LLM prompt, not a RAG filter
&lt;/h3&gt;

&lt;p&gt;The refusal came from the grounding instruction, not from retrieval (retrieval still returned Company A chunks). The prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are a financial analyst assistant. Answer the question using ONLY the context below.
If the answer is not in the context, say "not found in the provided documents."
Do not use outside knowledge. Be concise and cite the figure.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  two-quarter problem (known limitation)
&lt;/h3&gt;

&lt;p&gt;"Q1 FY27 outlook given in Q4 FY26" → parseQuarterFY grabs the first quarter (Q1 FY27) → filters to an empty set (no FY27 chunks) → 0 candidates → "not found," even though the answer is in Q4 FY26. The metadata filter that fixed quarter-blindness breaks on questions referencing two quarters. Real fix needs query-intent parsing (which quarter is the filter vs the target) — out of scope for now.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
    </item>
    <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>
