<?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: Katie</title>
    <description>The latest articles on DEV Community by Katie (@notjustsyntax).</description>
    <link>https://dev.to/notjustsyntax</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%2F3847261%2F619ae745-44ee-4c65-b823-9002a1b11760.png</url>
      <title>DEV Community: Katie</title>
      <link>https://dev.to/notjustsyntax</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/notjustsyntax"/>
    <language>en</language>
    <item>
      <title>Does AI get worse with more documents? Open-source vs paid models, tested.</title>
      <dc:creator>Katie</dc:creator>
      <pubDate>Mon, 12 Oct 2026 04:23:20 +0000</pubDate>
      <link>https://dev.to/notjustsyntax/does-ai-get-worse-with-more-documents-open-source-vs-paid-models-tested-45ie</link>
      <guid>https://dev.to/notjustsyntax/does-ai-get-worse-with-more-documents-open-source-vs-paid-models-tested-45ie</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/kaggle-2026-09-23"&gt;Kaggle Benchmarking Challenge&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Benchmarked
&lt;/h2&gt;

&lt;p&gt;Lots of companies now hand an AI model a stack of their own documents and ask it questions: "How many days of leave do I get?" or "Who signs off on this purchase?" Developers call this RAG, but the idea is simple. The AI reads what you give it and answers from that.&lt;/p&gt;

&lt;p&gt;There is a common worry about this setup: the more documents you hand over, the worse the answers get. The right page gets buried, an old copy gets picked by mistake, or the AI makes something up.&lt;/p&gt;

&lt;p&gt;I wanted to see whether that is true, so I built a test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The setup.&lt;/strong&gt; I invented a company and generated 50 short internal documents for it: leave policies, travel rules, supplier contracts, product sheets. Because the company is made up, no AI model could have seen these documents before. Then I asked each model 90 questions. For each question the model got a pile of documents, anywhere from 2 to 50, and had to do two things: give the answer, and say which document it came from. If the answer was not in the pile, it had to say "not found".&lt;/p&gt;

&lt;p&gt;I changed four things from question to question:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I changed&lt;/th&gt;
&lt;th&gt;The options&lt;/th&gt;
&lt;th&gt;What I wanted to learn&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Size of the pile&lt;/td&gt;
&lt;td&gt;2, 5, 10, 25 or 50 documents&lt;/td&gt;
&lt;td&gt;Do answers get worse as the pile grows?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where the right document sits&lt;/td&gt;
&lt;td&gt;Top, middle or bottom of the pile&lt;/td&gt;
&lt;td&gt;Does the AI skim over the middle?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kind of question&lt;/td&gt;
&lt;td&gt;One-step, two-step, or no answer exists&lt;/td&gt;
&lt;td&gt;Can it look things up, connect two documents, and admit when it doesn't know?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The other documents in the pile&lt;/td&gt;
&lt;td&gt;Unrelated, or very similar to the right one&lt;/td&gt;
&lt;td&gt;Do look-alike documents confuse it?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of these need a little more explanation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Out-of-date copies.&lt;/strong&gt; Ten documents have an older version with the same wording and different numbers. In the harder questions, the old version is in the pile next to the current one. This is what a real shared drive looks like.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-step questions.&lt;/strong&gt; Every document ends with a line like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Unresolved questions about this document are escalated to whoever approved the Annual Leave Policy for the Marrowby studio.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The question asks: who is that person? To answer, the model has to go to a second document, the Annual Leave Policy, and find the name of the person who approved it. One document points to the other, and the answer is only in the second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Models Tested
&lt;/h2&gt;

&lt;p&gt;I tested seven models. I picked them to cover the choices a team actually faces: the big expensive model or the small cheap one, and a paid service or a free model you run yourself.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5&lt;/td&gt;
&lt;td&gt;Anthropic's main model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;Anthropic's smaller, cheaper model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;OpenAI's main model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;OpenAI's smaller, cheaper model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;Google's fast model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 (31B)&lt;/td&gt;
&lt;td&gt;A free model from Google that you can run on your own machines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-20b&lt;/td&gt;
&lt;td&gt;A small free model from OpenAI that you can run on your own machines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The comparison I cared about most was big against small from the same company, and whether the free models could keep up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Findings
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A bigger pile did not make the answers worse
&lt;/h3&gt;

&lt;p&gt;This was the thing I set out to measure, and the answer was no. Here is how often each model got the right answer as the pile grew. Each box is about 12 questions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;2 docs&lt;/th&gt;
&lt;th&gt;5 docs&lt;/th&gt;
&lt;th&gt;10 docs&lt;/th&gt;
&lt;th&gt;25 docs&lt;/th&gt;
&lt;th&gt;50 docs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-20b&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;92%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100% (5 of 5)&lt;/td&gt;
&lt;td&gt;no data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;67%&lt;/td&gt;
&lt;td&gt;58%&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;75%&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;67%&lt;/td&gt;
&lt;td&gt;58%&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;42%&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four models scored the same with 50 documents as with 2. The two at the bottom were already struggling with only 2 documents, so the size of the pile is not what hurt them. Their numbers bounce around because each box is only 12 questions, and one wrong answer moves the score by 8 points.&lt;/p&gt;

&lt;p&gt;It also made no difference whether the right document was at the top, in the middle or at the bottom.&lt;/p&gt;

&lt;p&gt;One caution: my documents are short, less than a page each. Fifty of them come to about 16,500 words, roughly 35 pages. I can say the pile size did not matter up to there. I cannot say what happens with a pile ten times bigger.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two-step questions are where the small models failed
&lt;/h3&gt;

&lt;p&gt;When I split the results by kind of question, a very different picture appeared:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;One-step questions&lt;/th&gt;
&lt;th&gt;Two-step questions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5&lt;/td&gt;
&lt;td&gt;30 of 30&lt;/td&gt;
&lt;td&gt;30 of 30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;29 of 29&lt;/td&gt;
&lt;td&gt;30 of 30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;td&gt;30 of 30&lt;/td&gt;
&lt;td&gt;30 of 30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;17 of 17&lt;/td&gt;
&lt;td&gt;18 of 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-20b&lt;/td&gt;
&lt;td&gt;30 of 30&lt;/td&gt;
&lt;td&gt;29 of 30&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;30 of 30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6 of 30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;29 of 30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3 of 30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every model could find a single fact in a pile of 50 documents. But two of them could not follow a pointer from one document to another, even when the pile was just those two documents.&lt;/p&gt;

&lt;p&gt;They failed in different ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Haiku 4.5 gave up.&lt;/strong&gt; In 21 of its 24 wrong answers it said "not found". Often it had found the pointer and stopped there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ANSWER: NOT_FOUND
SOURCE: NONE

The Business Travel Policy for the Quillhaven lab (DOC-08) states that "Unresolved
questions about this document are escalated to whoever approved the Annual Leave
Policy for the Marrowby studio," but it does not provide the actual name of that person.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name it wanted was in another document in the same pile. It did not go and look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPT-5.4 mini answered without finishing the job.&lt;/strong&gt; In 20 of its 27 wrong answers it repeated the pointer as if it were the answer: "whoever approved the Equipment Maintenance Schedule for the Aldersgate office". In the other 7 it gave a real person's name, but the wrong person, and pointed to a real document as its source.&lt;/p&gt;

&lt;p&gt;These two failures are not equally bad. If an AI says "I couldn't find it", you know to check yourself. If it gives you the wrong name and cites a real document, you will probably believe it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The free models beat the small paid ones
&lt;/h3&gt;

&lt;p&gt;This surprised me most. Gemma 4, which is free to run yourself, got all 60 answerable questions right and named the correct source every time. gpt-oss-20b, a small free model, got 59 of 60. The two small paid models got 36 of 60 (Claude Haiku 4.5) and 32 of 60 (GPT-5.4 mini).&lt;/p&gt;

&lt;p&gt;The one weak spot: gpt-oss-20b usually had the right answer but sometimes named only one of the two documents it needed. It listed every source on 52 of 60 answers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Answerable questions right&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4&lt;/td&gt;
&lt;td&gt;Free, open&lt;/td&gt;
&lt;td&gt;60 of 60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-oss-20b&lt;/td&gt;
&lt;td&gt;Free, open&lt;/td&gt;
&lt;td&gt;59 of 60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;Small paid&lt;/td&gt;
&lt;td&gt;36 of 60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.4 mini&lt;/td&gt;
&lt;td&gt;Small paid&lt;/td&gt;
&lt;td&gt;32 of 60&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The paid models' failures may be partly about how they follow instructions.&lt;/strong&gt; Haiku mostly said "not found" instead of guessing, which is cautious behaviour, not an inability to read. A differently worded question might change its score a lot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Free" isn't free to run.&lt;/strong&gt; You don't pay per question, but you need your own hardware, and Gemma 4 at that size needs a serious machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  No model made things up
&lt;/h3&gt;

&lt;p&gt;A third of my questions had no answer anywhere in the documents. Every model said "not found" to every one of them, at every pile size. I expected at least a few invented answers with 50 documents and got none.&lt;/p&gt;

&lt;p&gt;The out-of-date copies also fooled almost no one. Each model saw about 27 questions where an old version was sitting in the pile. Only one answer in the whole test used the old number.&lt;/p&gt;

&lt;h3&gt;
  
  
  What these results cannot tell you
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GPT-5.5 did not finish.&lt;/strong&gt; I ran out of usage allowance partway through, and it never got to answer 35 of its 90 questions, including all of the 50-document ones. It was perfect on the 55 it did answer. I left the missing ones out of my tables. Kaggle counts them as wrong, so its score looks lower there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each model ran once.&lt;/strong&gt; I did not repeat the test to see how much the scores move between runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The groups are small.&lt;/strong&gt; A difference of one or two questions means nothing. The gap on two-step questions (30 of 30 against 3 of 30) does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The documents are invented.&lt;/strong&gt; They are tidier than a real company's files, and five of the seven models scored at or near perfect, so this test is too easy to tell those five apart.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What I would test next
&lt;/h3&gt;

&lt;p&gt;My test turned out to be easy on the thing I built it for and revealing on something else. So next I would:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Make the pile much bigger than 50 documents, and make the documents longer, to find where the scores finally drop.&lt;/li&gt;
&lt;li&gt;Add three-step and four-step questions. If two steps separate the small models from the rest, more steps may separate the rest from each other.&lt;/li&gt;
&lt;li&gt;Run GPT-5.5 again with enough allowance to finish.&lt;/li&gt;
&lt;li&gt;Run every model several times.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The practical lesson for now: in this test, handing the AI more documents was not the risk. The risk was asking a small model a question whose answer is spread across two documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Benchmark
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Task:&lt;/strong&gt; &lt;a href="https://www.kaggle.com/benchmarks/tasks/katienguyen27/rag-doc-scaling/2" rel="noopener noreferrer"&gt;rag-doc-scaling on Kaggle Benchmarks&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dataset (50 documents, 90 cases):&lt;/strong&gt; &lt;a href="https://www.kaggle.com/datasets/katienguyen27/rag-doc-scaling" rel="noopener noreferrer"&gt;katienguyen27/rag-doc-scaling&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>From Vanilla JS to React: React Makes More Sense When the Problem Comes First</title>
      <dc:creator>Katie</dc:creator>
      <pubDate>Sat, 28 Mar 2026 08:10:19 +0000</pubDate>
      <link>https://dev.to/notjustsyntax/from-vanilla-js-to-react-react-makes-more-sense-when-the-problem-comes-first-2k2j</link>
      <guid>https://dev.to/notjustsyntax/from-vanilla-js-to-react-react-makes-more-sense-when-the-problem-comes-first-2k2j</guid>
      <description>&lt;p&gt;I had an e-commerce project — plain HTML/JavaScript frontend, Node.js/Express backend. I took on a more interesting challenge: migrating the frontend from vanilla HTML/JS to React.&lt;/p&gt;

&lt;p&gt;Migrating code you already wrote and understand is one of the better ways to go deeper with React. You're not learning the syntax and the concepts at the same time — you already know what the code does, so you can focus entirely on &lt;em&gt;why&lt;/em&gt; each React pattern exists. This is what that looked like.&lt;/p&gt;




&lt;h2&gt;
  
  
  The original version
&lt;/h2&gt;

&lt;p&gt;Each page was a separate HTML file loaded its own JS modules. State lived in the DOM. Navigation caused full page reloads. The patterns were familiar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;updateCartIcon()&lt;/code&gt; fetches the cart total from the backend and updates the cart icon in the banner — it had to be manually called in every place that could affect the cart count&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;showHideMenuItems()&lt;/code&gt; checks if the user is logged in and toggles the visibility of login, signup, and logout buttons by directly setting &lt;code&gt;element.style.display&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Any change to the shared header or footer meant touching every HTML file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are bugs, but they're all symptoms of the same root problem: &lt;strong&gt;state is scattered and has to be manually synchronized&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pages → components
&lt;/h2&gt;

&lt;p&gt;The mapping is direct. Each HTML file becomes a page component, and React Router replaces file-based navigation. &lt;code&gt;window.location.href = '/cart.html'&lt;/code&gt; becomes &lt;code&gt;navigate('/cart')&lt;/code&gt;. &lt;code&gt;&amp;lt;a href="/login.html"&amp;gt;&lt;/code&gt; becomes &lt;code&gt;&amp;lt;Link to="/login"&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The more meaningful difference is that only the page component swaps out on navigation — the rest of the component tree stays mounted, so state persists across the session without any extra work.&lt;/p&gt;




&lt;h2&gt;
  
  
  Extracting shared UI
&lt;/h2&gt;

&lt;p&gt;Every page shared the same banner, header, and footer. In the old app this was solved with copy-paste. In React, a &lt;code&gt;Layout&lt;/code&gt; component wraps all routes via React Router's &lt;code&gt;&amp;lt;Outlet /&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Layout
├── Banner
├── Header
├── &amp;lt;Outlet /&amp;gt;   ← current page renders here
└── Footer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Banner itself had three distinct responsibilities — a mobile menu toggle, auth-aware navigation links, and a cart icon — so it was broken into &lt;code&gt;Banner&lt;/code&gt;, &lt;code&gt;Navigation&lt;/code&gt;, and &lt;code&gt;CartIcon&lt;/code&gt; components.&lt;/p&gt;

&lt;p&gt;The toggle was the first real state decision: the button lives in &lt;code&gt;Banner&lt;/code&gt;, and &lt;code&gt;Navigation&lt;/code&gt; receives &lt;code&gt;isOpen&lt;/code&gt; as a prop. The state lives in the closest common ancestor, which is the standard lift-state-up pattern. Straightforward.&lt;/p&gt;




&lt;h2&gt;
  
  
  The custom hook trap
&lt;/h2&gt;

&lt;p&gt;The cart count was where things got more interesting.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CartIcon&lt;/code&gt; in the banner needs to display the total items the user added to the cart. &lt;code&gt;ProductList&lt;/code&gt; needs to refresh that total after adding an item. Two components, one shared value — the obvious move is a custom hook that fetches the total from the backend and exposes a &lt;code&gt;refresh&lt;/code&gt; function to re-fetch on demand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useCartCount&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cartCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCartCount&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="mi"&gt;0&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;refresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &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="cm"&gt;/* fetch /api/cart/cart-count */&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;refresh&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;cartCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refresh&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;If you call this hook in both components, it looks clean. It doesn't work.&lt;/p&gt;

&lt;p&gt;The issue is obvious in hindsight: &lt;strong&gt;each hook call is an independent instance&lt;/strong&gt;. &lt;code&gt;CartIcon&lt;/code&gt; and &lt;code&gt;ProductList&lt;/code&gt; each had their own isolated &lt;code&gt;cartCount&lt;/code&gt; state. When &lt;code&gt;ProductList&lt;/code&gt; called &lt;code&gt;refresh()&lt;/code&gt;, it updated its own copy. &lt;code&gt;CartIcon&lt;/code&gt; never knew anything changed.&lt;/p&gt;

&lt;p&gt;Extracting stateful logic into a custom hook does not automatically make that state shared.&lt;/p&gt;




&lt;h2&gt;
  
  
  Context as the fix
&lt;/h2&gt;

&lt;p&gt;The fix was to move ownership of the cart count into a provider above both components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CartContextProvider
├── Banner
│   └── CartIcon  ← reads cartCount
└── Home
    └── ProductList  ← calls refresh()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are two useful hook roles here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A private hook can manage the provider’s state and fetching logic.&lt;/li&gt;
&lt;li&gt;A public hook can read the values shared through context.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The custom hook still exists — but now it runs inside &lt;code&gt;CartContextProvider&lt;/code&gt;, whenever the provider renders, but its state persists across those renders. Everything downstream reads from that one instance via &lt;code&gt;useContext&lt;/code&gt;. When &lt;code&gt;ProductList&lt;/code&gt; calls &lt;code&gt;refresh()&lt;/code&gt;, it's the same function &lt;code&gt;CartIcon&lt;/code&gt; is subscribed to.&lt;/p&gt;

&lt;p&gt;I could also keep the state and fetching logic directly inside CartProvider. Extracting that implementation into a private hook is optional.&lt;/p&gt;

&lt;p&gt;The same pattern applied to auth. &lt;code&gt;Navigation&lt;/code&gt; needs &lt;code&gt;isLoggedIn&lt;/code&gt; to render the right links. &lt;code&gt;CartIcon&lt;/code&gt; needs it to decide whether to render at all. Pulling both into an &lt;code&gt;AuthContext&lt;/code&gt; replaced the old &lt;code&gt;showHideMenuItems()&lt;/code&gt; with declarative rendering — components just read state and React handles the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where login logic lives — and why it matters
&lt;/h2&gt;

&lt;p&gt;The first version had the login fetch inside the &lt;code&gt;Login&lt;/code&gt; component. It sent the request, and on success, called &lt;code&gt;navigate('/')&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem: the &lt;code&gt;Login&lt;/code&gt; component had no connection to &lt;code&gt;AuthContext&lt;/code&gt;. After a successful login, &lt;code&gt;isLoggedIn&lt;/code&gt; in the context hadn't changed. The banner still showed "Log in". The app looked broken until you refreshed.&lt;/p&gt;

&lt;p&gt;The fix is to move &lt;code&gt;login()&lt;/code&gt; and &lt;code&gt;logout()&lt;/code&gt; out of the component and into &lt;code&gt;AuthContext&lt;/code&gt; — so they have direct access to &lt;code&gt;setUser&lt;/code&gt; and can update the shared state immediately on success.&lt;/p&gt;

&lt;p&gt;This also revealed a cleaner separation of concerns. &lt;code&gt;useAuth&lt;/code&gt; should only do one thing: check the session on mount and expose the user state. The actions — login, logout — belong in the context that owns that state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;useAuth&lt;/code&gt;&lt;/strong&gt; — runs &lt;code&gt;checkAuth&lt;/code&gt; on mount, returns &lt;code&gt;{ user, setUser }&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AuthContext&lt;/code&gt;&lt;/strong&gt; — defines &lt;code&gt;login()&lt;/code&gt; and &lt;code&gt;logout()&lt;/code&gt;, uses &lt;code&gt;setUser&lt;/code&gt; from the hook to update state, provides everything to the tree&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Login&lt;/code&gt;&lt;/strong&gt; - handles the form, error messages, button disabled state, loading indicators, and navigation after success&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This settled into a useful pattern for the app: &lt;strong&gt;the provider manages shared auth, the hook provides access, the component owns UI&lt;/strong&gt; — error messages, button disabled state, loading indicators. The &lt;code&gt;Login&lt;/code&gt; component now just calls &lt;code&gt;login()&lt;/code&gt; through useAuth() and reacts to the response.&lt;/p&gt;




&lt;h2&gt;
  
  
  Session cookies vs localStorage
&lt;/h2&gt;

&lt;p&gt;There are two common ways to handle auth persistence on the frontend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JWT + localStorage&lt;/strong&gt; — on login, the backend returns a token. The frontend stores it in &lt;code&gt;localStorage&lt;/code&gt; and manually attaches it to every request as an &lt;code&gt;Authorization&lt;/code&gt; header. It persists across refreshes, but the token is exposed to JavaScript, making it vulnerable to XSS attacks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cookie-based auth&lt;/strong&gt; — on login, the backend sets a cookie containing a session identifier or token. The browser stores it and automatically attaches it to requests that match the cookie's scope and browser policies. With HttpOnly, JavaScript can't directly read the cookie. HttpOnly prevents direct cookie theft through JavaScript, but it doesn't stop an XSS attack from making authenticated requests.Cookie-based auth also needs appropriate CSRF protection and cookie settings such as Secure and SameSite.&lt;/p&gt;

&lt;p&gt;JWTs can also be stored in cookies. The token format and the storage mechanism are separate choices.&lt;/p&gt;

&lt;p&gt;For same-origin requests, fetch includes cookies by default. If the frontend and backend are on different origins, the frontend needs credentials: 'include', and the backend needs the appropriate CORS configuration. This option tells the browser to include them, so the backend can read the session and know who you are.&lt;/p&gt;

&lt;p&gt;This app uses an HttpOnly cookie for its session. The consequence for React state is that &lt;code&gt;isLoggedIn&lt;/code&gt; doesn't survive a page refresh — it's just JavaScript in memory. On every mount, &lt;code&gt;useAuth&lt;/code&gt; calls &lt;code&gt;/api/auth/me&lt;/code&gt;. If the session cookie is still valid, the backend confirms the user and state is restored. If not, the user is logged out.&lt;br&gt;
While that check is pending, auth is still loading. A failed network request is also different from the backend confirming that the session is invalid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React state reflects auth. The backend validates it. The cookie carries the credential.&lt;/strong&gt; Keeping those roles separate is the right mental model.&lt;/p&gt;




&lt;h2&gt;
  
  
  The actual takeaway
&lt;/h2&gt;

&lt;p&gt;The migration made one thing very concrete: &lt;strong&gt;in my old app, UI updates were driven by manual DOM changes; in React, the UI was rendered from state.&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In the old app, &lt;code&gt;showHideMenuItems()&lt;/code&gt; existed because the DOM didn't know about login state — you had to reach in and update it manually every time something changed. In React, &lt;code&gt;isLoggedIn&lt;/code&gt; changes once and everything that depends on it re-renders. The DOM reflects state, rather than being the thing you manage directly.&lt;/p&gt;

&lt;p&gt;Vanilla JS can use explicit state as its source of truth too. React gave this app a consistent way to organize that relationship.&lt;/p&gt;

&lt;p&gt;That shift — from imperative DOM manipulation to declarative state — is what every React pattern ultimately comes back to. Lifting state up, custom hooks, Context — each one is just a different answer to the same question: how far does this state need to reach?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
    </item>
  </channel>
</rss>
