<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>hooninedev.com</title>
        <link>https://hooninedev.com/en</link>
        <description>프론트엔드 개발자 이지훈(후니)의 기술 블로그. React, TypeScript, Next.js 로 만들면서 겪은 설계 판단과 문제 해결 과정, 성능과 관측 실측, 학습 노트를 남깁니다.</description>
        <lastBuildDate>Thu, 15 Oct 2026 20:27:07 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <copyright>All rights reserved 2026, 이지훈</copyright>
        <item>
            <title><![CDATA[Layers in a React App]]></title>
            <link>https://hooninedev.com/en/261001</link>
            <guid isPermaLink="false">https://hooninedev.com/en/261001</guid>
            <pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about how to split the code of a React app into the screen, the business rules, and data access. It is written for developers whose component holds fetch, calculation, and...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about how to split the code of a React app into the screen, the business rules, and data access. It is written for developers whose component holds fetch, calculation, and render all together, so that every fix means reading the whole thing. By the end you will know which signals tell you to pull out a hook, a pure component, a domain object, a Strategy, and a network client, in what order, and where that structure breaks when you carry it over as is.</p>
<p>The backbone is <a href="https://martinfowler.com/articles/modularizing-react-apps.html" target="_blank" rel="noopener noreferrer">Modularizing React Applications with Established UI Patterns</a>, which Juntao Qiu of Thoughtworks published as a series on martinfowler.com in February 2023. I did not transcribe the original; I retold it in my own words, and every figure in this post comes from that article. On top of it I added the results of cloning the author's <a href="https://github.com/abruzzi/payment-round-up-refactoring" target="_blank" rel="noopener noreferrer">example repository</a> and running the type check and tests, along with other material that deals with the same problem.</p>
<h2 id="react-is-a-view-library"><a class="anchor" href="#react-is-a-view-library">React is a view library</a></h2>
<p>When you first learn React, the most appealing idea is that UI is a pure function that turns data into DOM. To a degree, that is true. But the moment you send a request to a server or navigate to another page, the component is no longer pure. Add tangled global and local state, and the code gets complicated fast.</p>
<p>The author's answer is to change the point of view. There is no special breed of software called a "React application." React is a library for drawing UI, and where to put calculations or business rules was never its concern to begin with. What sits on top of it is an ordinary JavaScript app. If so, we can reuse the designs we have used since the days of desktop GUIs, in particular the approach of splitting code into three layers: presentation, domain, and data.</p>
<p>A real frontend app has more than views. It has a router, local storage, caches at several levels, network requests, integrations with external services and login, security, logging, and performance tuning. Push all of that into components and hooks, and within one file a line that requests the order status is followed by a line that trims leading whitespace from a string, and then by a line that navigates to another screen. The reader has to keep switching levels of abstraction and jumping back and forth.</p>
<p>The reasons for splitting come down to two. The screen changes more often than the business rules. And once the two are separated, you only have to think about one thing at a time. The second reason is what Martin Fowler names as the biggest benefit of this separation in <a href="https://martinfowler.com/bliki/PresentationDomainDataLayering.html" target="_blank" rel="noopener noreferrer">PresentationDomainDataLayering</a>. You can think about the three topics relatively independently, which narrows what you have to pay attention to.</p>
<h2 id="five-stages-of-a-growing-app"><a class="anchor" href="#five-stages-of-a-growing-app">Five stages of a growing app</a></h2>
<p>Before getting into the refactoring, the author shows in five figures how the structure changes as an app grows. What matters is the trigger for moving to the next stage. It is not a fixed point in time; the pain that appears at one stage calls for the next. The colors mean the same thing in all five figures. Light green is a container component that holds state, dark green is a presentational component that only draws, purple is a hook, blue is a domain object, and orange is infrastructure such as the network.</p>
<p>At first, a single component owns the network request, state, calculation, and render. For a small app or a one-off project this is enough, and because the code looks like HTML it is actually easy to read. Once code that loops over a list to build items gets mixed with code that configures an external component, and it starts taking time to read what is going on, you move to the next stage.</p>
<p><picture><source type="image/webp" srcset="/content/261001/1.720w.webp 720w, /content/261001/1.1440w.webp 1440w, /content/261001/1.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/1.png" alt="A single component owns network requests, state management, domain logic, and rendering, and calls the API directly" style="max-width:min(720px, 100%);height:auto;" width="1600" height="661" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>As the screen grows, you split components along the shape of the resulting HTML. Components that only draw break off, but the top component still holds the network request, the code that reshapes the response for the screen, and the code that gathers the data to send to the server. None of the three is UI, yet they live inside a component.</p>
<p><picture><source type="image/webp" srcset="/content/261001/2.720w.webp 720w, /content/261001/2.1440w.webp 1440w, /content/261001/2.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/2.png" alt="Components that only draw have been split off, but network requests and domain logic remain in the component above them" style="max-width:min(720px, 100%);height:auto;" width="1600" height="984" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Next, you move state and its changes into a custom hook. Then what is left inside the hook is pure calculation that is neither a side effect nor state.</p>
<p><picture><source type="image/webp" srcset="/content/261001/3.720w.webp 720w, /content/261001/3.1440w.webp 1440w, /content/261001/3.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/3.png" alt="Network requests and domain logic have moved into a hook, but the two are mixed together inside the hook" style="max-width:min(720px, 100%);height:auto;" width="1600" height="872" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Move that calculation into an object unrelated to React, and a domain object appears. Data format conversion, null checks, and fallback values go there. As objects multiply, inheritance or polymorphism starts to become necessary.</p>
<p><picture><source type="image/webp" srcset="/content/261001/4.720w.webp 720w, /content/261001/4.1440w.webp 1440w, /content/261001/4.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/4.png" alt="The hook keeps only state, while domain logic goes to a Domain object and network requests go to a Fetcher" style="max-width:min(720px, 100%);height:auto;" width="1600" height="790" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Finally, when you see objects that belong to no UI and do not care whether data comes from the server or from local storage or a cache, you group them into a separate model layer.</p>
<p><picture><source type="image/webp" srcset="/content/261001/5.720w.webp 720w, /content/261001/5.1440w.webp 1440w, /content/261001/5.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/5.png" alt="The middle band is the model layer, with components and hooks above it and infrastructure such as Fetcher and Adaptor below it" style="max-width:min(720px, 100%);height:auto;" width="1600" height="917" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The figures alone are abstract. Let's walk through these five stages once with real code. The example the author chose is the payment screen of an online order.</p>
<h2 id="starting-from-a-single-payment-screen"><a class="anchor" href="#starting-from-a-single-payment-screen">Starting from a single payment screen</a></h2>
<p>Payment methods come from server configuration and differ by country. If the server returns even one, cash payment is appended at the end and selected by default; if it returns none, nothing is shown.</p>
<p><picture><source type="image/webp" srcset="/content/261001/6.720w.webp 720w, /content/261001/6.1440w.webp 1440w, /content/261001/6.1600w.webp 1600w" sizes="(max-width: 632px) calc(100vw - 32px), 600px"><img src="/content/261001/6.png" alt="A screen with apple, google, and cash payment radio buttons in the payment section below the order details, with cash payment selected" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The initial code has the shape you often see in tutorials. Inside <code>useEffect</code> it fetches, reshapes the response for the screen, appends cash payment, and draws a list of radio buttons. Below is my shortened version.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">LocalPaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(url)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">res</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (methods.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([]);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> extended</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ provider: m.name, label: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`Pay with ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">name</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        extended.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ provider: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, label: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay in cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(extended);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {paymentMethods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">method</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">label</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{method.provider}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">input</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> type</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"radio"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultChecked</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{method.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          {method.label}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      ))}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>${amount}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>At this size it is not a problem. But to change this component you have to understand four things together: how the network request starts, how server data is turned into a shape the screen knows, how one payment method is drawn, and how the whole payment section is drawn. As the code grows, the reader has to keep moving between these four.</p>
<h2 id="hooks-and-pure-components"><a class="anchor" href="#hooks-and-pure-components">Hooks and pure components</a></h2>
<p>The first thing to pull out is state and fetch. The signal is simple. <strong>When code unrelated to render takes up more than half of the component body</strong>, move it into a hook. <code>usePaymentMethods</code> takes care of fetching and conversion, and the component just receives the array from the hook and draws it.</p>
<p>The second is the JSX that draws the list. <strong>When you see a block that can be drawn from props alone</strong>, split it into a component. <code>PaymentMethods</code> is a pure component that receives only an array of payment methods, so it is easy to test and reuse. Even if an <code>onSelect</code> callback that reports the selection is added later, it does not touch outside state, so it stays pure.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">LocalPaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 위의 fetch와 변환 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, []);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { paymentMethods };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">PaymentMethods</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> paymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{paymentMethods} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>${amount}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>The author calls both of these moves <a href="https://refactoring.com/catalog/extractFunction.html" target="_blank" rel="noopener noreferrer">Extract Function</a> from the refactoring catalog, because in React a component is also a function. The important point is that no new concept was brought in; one function was simply split into two.</p>
<p>This may look finished, but two spots remain. Inside the view there is the rule "select cash by default," and inside the hook there is an anonymous function that reshapes the response for the screen. The author calls this kind of business rule seeping into the view and the hook a logic leak.</p>
<h2 id="rules-gathered-in-a-domain-object"><a class="anchor" href="#rules-gathered-in-a-domain-object">Rules gathered in a domain object</a></h2>
<p>The third signal is <strong>when decisions about the same data are scattered across the view and the hook</strong>. Gather the leaked decisions and conversions into a single <code>PaymentMethod</code> class. As I covered in <a href="/260418" target="_blank" rel="noopener noreferrer">domain models</a> on this blog, the idea is to put the rules next to the data.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">private</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> remote</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> provider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.remote.name; }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "Pay in cash"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `Pay with ${</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">provider</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  get</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isDefaultMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.provider </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RemotePaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  methods.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(m)), </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> })];</span></span></code></pre></figure>
<p>The radio button in the view now asks the object, with <code>defaultChecked={method.isDefaultMethod}</code>. The cash payment default is also represented as an instance of the same class. Because what the server provided and what the app appended now have the same shape, the view no longer needs to tell them apart.</p>
<p><picture><source type="image/webp" srcset="/content/261001/7.720w.webp 720w, /content/261001/7.1440w.webp 1440w, /content/261001/7.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/7.png" alt="Payment calls usePaymentMethods, the hook creates PaymentMethod objects, and PaymentMethods only draws" style="max-width:min(720px, 100%);height:auto;" width="1600" height="662" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The author cites easier testing as a benefit of this structure. The original stops at that statement, but seeing how much easier it gets is faster in code. Below is a test I wrote. No React, no renderer, no fetch mock.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">test</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"서버가 준 결제 수단 끝에 현금 결제를 기본값으로 붙인다"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> methods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([{ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"apple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, { name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"google"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.label)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay with apple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay with google"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Pay in cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.isDefaultMethod).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.provider)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cash"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">test</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"서버가 아무것도 주지 않으면 현금 결제도 보여 주지 않는다"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([])).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toEqual</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>To check the same rules in the initial code, you had to render the component, intercept fetch, and wait until the radio buttons were drawn. The moment the rules came out as an object, the test became a single function call.</p>
<p>Another benefit is that when a new requirement arrives, it already has a place to go. At this point the file structure looks like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>src</span></span>
<span data-line=""><span>├── components</span></span>
<span data-line=""><span>│   ├── Payment.tsx</span></span>
<span data-line=""><span>│   └── PaymentMethods.tsx</span></span>
<span data-line=""><span>├── hooks</span></span>
<span data-line=""><span>│   └── usePaymentMethods.ts</span></span>
<span data-line=""><span>└── models</span></span>
<span data-line=""><span>    └── PaymentMethod.ts</span></span></code></pre></figure>
<p>Whether the structure really holds up is only known when a new requirement arrives. Here the author adds one more feature.</p>
<h2 id="a-donation-feature-and-the-hook-as-a-state-machine"><a class="anchor" href="#a-donation-feature-and-the-hook-as-a-state-machine">A donation feature and the hook as a state machine</a></h2>
<p>The new requirement is to round up the order amount and let the user donate the difference to charity. For a $19.80 order, it asks about a $0.20 donation, and if the user agrees, the button shows $20.</p>
<p><picture><source type="image/webp" srcset="/content/261001/8.720w.webp 720w, /content/261001/8.1440w.webp 1440w, /content/261001/8.1600w.webp 1600w" sizes="(max-width: 632px) calc(100vw - 32px), 600px"><img src="/content/261001/8.png" alt="The payment screen with a checkbox for a $0.2 donation added below the payment methods" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The author deliberately puts the agreement state and the calculation inside the payment component at first. With the checkbox markup, the message branching, and the total calculation all arriving at once, the component gets heavy again. The cleanup follows the same order as before. State and calculation go to a <code>useRoundUp</code> hook, message assembly goes to a helper function, and the checkbox markup goes to a <code>DonationCheckbox</code> component.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRoundUp</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">agreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setAgreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMemo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    total: agreeToDonate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    tip: </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">parseFloat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toPrecision</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }), [amount, agreeToDonate]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateAgreeToDonate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setAgreeToDonate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">v</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">v);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { total, tip, agreeToDonate, updateAgreeToDonate };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p><code>toPrecision(10)</code> is something the original does not explain. When I ran it on Node 24, <code>20 - 19.8</code> came out as <code>0.1999999999999993</code>. It is a device for scrubbing off floating point error.</p>
<p>The author sees this kind of hook as "a state machine behind the view." When an event comes from the UI, it produces new state, and the new state triggers another render. Hooks were originally created so that several components could share logic, but the author's position is that even with a single user, a hook is worth extracting because it lets the component focus on render. I would add one thing. As React's official docs point out in <a href="https://react.dev/learn/reusing-logic-with-custom-hooks" target="_blank" rel="noopener noreferrer">Reusing Logic with Custom Hooks</a>, what a custom hook shares is the logic that handles state, not the state itself. This state machine is created separately for each component that calls the hook.</p>
<p>After the cleanup, the payment component calls two hooks and lays out two child components and a button. A feature was added, yet the payment component's role stayed the same.</p>
<p><picture><source type="image/webp" srcset="/content/261001/9.720w.webp 720w, /content/261001/9.1440w.webp 1440w, /content/261001/9.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/9.png" alt="Under Payment sit two hooks, useRoundUp and usePaymentMethods, and two child components, PaymentMethods and DonationCheckbox" style="max-width:min(720px, 100%);height:auto;" width="1600" height="775" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h2 id="per-country-rules-and-shotgun-surgery"><a class="anchor" href="#per-country-rules-and-shotgun-surgery">Per-country rules and shotgun surgery</a></h2>
<p>The next requirement is to use a different rounding unit per country. Japan rounds to 100 yen, Denmark to 10 kroner. Saying "it seems like an easy fix," the author deliberately shows first the path of taking the country code as a prop and adding branches.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// useRoundUp 안</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: agreeToDonate</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> amount,</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 체크박스 문구 helper 안</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> currencySign</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "¥"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 버튼 JSX 안</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{countryCode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "JP"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "¥"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}{total}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>The same <code>countryCode === "JP"</code> check has landed in three places, one each in the hook, the helper, and the button. Adding Denmark means editing all three places again, and once ternaries become unmanageable, a table that looks up currency signs by country code appears. That table, too, ends up referenced separately from many places. This smell, where one change forces you to edit several modules at once, is called shotgun surgery.</p>
<p><picture><source type="image/webp" srcset="/content/261001/10.720w.webp 720w, /content/261001/10.1440w.webp 1440w, /content/261001/10.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/10.png" alt="Colored bars representing per-country rules scattered across several components, hooks, and domain objects" style="max-width:min(720px, 100%);height:auto;" width="1600" height="902" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>What makes this stage different from the previous three is that the signal comes not from the code but from a <strong>change request</strong>. Looking only at the current code, three branches are not hard to read. The problem only shows when you count how many places you have to edit to add one more country. So where should the scattered branches be gathered?</p>
<h2 id="per-country-differences-gathered-in-a-strategy"><a class="anchor" href="#per-country-differences-gathered-in-a-strategy">Per-country differences gathered in a Strategy</a></h2>
<p>The author's answer is to turn the per-country difference itself into an object. First, using <a href="https://refactoring.com/catalog/extractClass.html" target="_blank" rel="noopener noreferrer">Extract Class</a> and <a href="https://refactoring.com/catalog/replaceConditionalWithPolymorphism.html" target="_blank" rel="noopener noreferrer">Replace Conditional with Polymorphism</a>, he creates one interface and a per-country implementation.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategyAU</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> implements</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 1달러 단위 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategyJP</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> implements</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 100엔 단위 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span></code></pre></figure>
<p>Once they are built, it turns out the only things that differ between implementations are the currency sign and the rounding function, and the rest, like <code>getTip</code>, is identical. So the author uses <a href="https://refactoring.com/catalog/inlineClass.html" target="_blank" rel="noopener noreferrer">Inline Class</a> to merge the implementations into a single class that takes only the differing parts through its constructor. The reason you can take the rounding algorithm as a function instead of creating a subclass per country is that functions are values in JavaScript. Below is my shortened version of the result.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> currencySign</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">private</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> roundUp</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">roundUp</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n); }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> parseFloat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> n).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toPrecision</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)); }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> japan</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"¥"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">japan.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRoundUpAmount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3312</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 3400</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">japan.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getTip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3312</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);           </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 88</span></span></code></pre></figure>
<p>The hook and the component no longer know about countries. They receive this one object and simply use <code>strategy.getRoundUpAmount(amount)</code> and <code>strategy.currencySign</code>. Adding a country means creating one more object, and there are no files to edit.</p>
<p><picture><source type="image/webp" srcset="/content/261001/11.720w.webp 720w, /content/261001/11.1440w.webp 1440w, /content/261001/11.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/11.png" alt="The component looks only at PaymentStrategy, with three per-country implementations gathered behind it" style="max-width:min(720px, 100%);height:auto;" width="1600" height="847" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Going through an interface and then merging back may look like wasted motion. I think this is the most instructive part of the original. Polymorphism was not the goal but a tool for exposing what is common and what differs, and once it became clear that the difference had shrunk to a single function, the class hierarchy was removed. Knowing when to remove a pattern again is a harder judgment than bringing it in.</p>
<h2 id="moving-fetch-out-of-the-hook"><a class="anchor" href="#moving-fetch-out-of-the-hook">Moving fetch out of the hook</a></h2>
<p>The last thing to pull out is the fetch and conversion inside <code>usePaymentMethods</code>. A hook is a React concept, so it stays on the view side. Add error handling and retries and the hook quickly bloats, and if you move to another view library, the hook cannot be used. A plain function, on the other hand, can be used anywhere.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchPaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`${</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">API</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/payment-methods?countryCode=AU`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> convertPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> response.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> usePaymentMethods</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">paymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">PaymentMethod</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]>([]);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetchPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setPaymentMethods);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { paymentMethods };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>The author writes that this function acts like an Anti-Corruption Layer or a Gateway. That means even if the structure of the server response changes, the place to fix stays within this one function. The two names are not the same thing, though. Fowler's <a href="https://martinfowler.com/articles/gateway-pattern.html" target="_blank" rel="noopener noreferrer">Gateway</a> is an object that wraps access to an external system in one place, while Anti-Corruption Layer is a term from Eric Evans's DDD for a boundary that translates an external model with a different semantic system so it does not corrupt your own model. The server response in this example is just <code>{ name }</code>, so there is almost no difference in meaning to translate, and I think Gateway is the more accurate name. The same spot becomes an Anti-Corruption Layer when the server is a legacy system owned by another team and the very meaning of a payment method differs.</p>
<p>The final structure looks like this. Render is in components, state in hooks, rules in domain objects, and network requests in functions.</p>
<p><picture><source type="image/webp" srcset="/content/261001/12.720w.webp 720w, /content/261001/12.1440w.webp 1440w, /content/261001/12.1600w.webp 1600w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/261001/12.png" alt="The final structure, with Payment and its child components, the useRoundUp and usePaymentMethods hooks, the PaymentStrategy and PaymentMethod domain objects, and a Fetcher each in its own box" style="max-width:min(720px, 100%);height:auto;" width="1600" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The author lists five benefits of splitting things this way. It becomes easier to find where a defect is. Code becomes easier to reuse and compose. It becomes easier to read. Adding features does not shake the whole. And because domain logic does not know about the view, you can swap out just the view layer. For the last one, the author himself adds the caveat that it is very rare in most projects. It is also worth remembering that none of the five comes with measurements.</p>
<p>That is the storyline of the original. But when you actually run this code, things show up that were not visible in the article.</p>
<h2 id="what-running-the-example-reveals"><a class="anchor" href="#what-running-the-example-reveals">What running the example reveals</a></h2>
<p>I cloned the author's example repository (16 commits, last commit <code>de186c5</code>) and ran <code>tsc --noEmit</code> and the 7 tests. Both passed, and the helpers whose definitions are missing from the article were in the repository's <code>src/utils.ts</code>. But looking into the structure, I found three things you should know before carrying it over.</p>
<h3 id="a-strategy-created-anew-on-every-render"><a class="anchor" href="#a-strategy-created-anew-on-every-render">A Strategy created anew on every render</a></h3>
<p>The payment component receives a Strategy as a prop and, if none is passed, creates a default with <code>new</code>. And <code>useRoundUp</code> puts that Strategy in the dependencies of <code>useMemo</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Payment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  amount,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  strategy </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, roundUpToNearestInteger),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">strategy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PaymentStrategy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">total</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tip</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRoundUp</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(amount, strategy); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안에서 [agreeToDonate, amount, strategy]</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>JavaScript default parameters are <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters" target="_blank" rel="noopener noreferrer">evaluated at every call</a>. And <code>useMemo</code> compares dependencies with <code>Object.is</code>. Put the two together, and every render creates a new instance and the memo recomputes every time. When I rendered the same structure four times in React 18, the calculation ran 4 times with the default and once when the same instance was passed. The repository's <code>App.tsx</code> passes no Strategy with <code>&#x3C;Payment amount={19.9} /></code>, so the app's execution path is exactly the default case.</p>
<p>The calculation in this example is a few additions, so there is no cost. The problem is the structure. Neither the article nor the repository settles who creates the Strategy and where it is passed from; only the tests create Japan and Denmark instances. If it were me, I would keep the instances as module-level constants and look them up by country code to pass them in.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> strategies</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  AU: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"$"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  JP: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CountryPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"¥"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 100</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Payment amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{amount} strategy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{strategies[countryCode]} /></span></span></code></pre></figure>
<h3 id="the-repositorys-fetch-has-no-dependency-array"><a class="anchor" href="#the-repositorys-fetch-has-no-dependency-array">The repository's fetch has no dependency array</a></h3>
<p>The <code>useEffect</code> in the article's code has <code>[]</code>, but <code>usePaymentMethods</code> in the repository has no dependency array from the first commit to the last. An effect without an array runs again on every render. When the response arrives, it replaces state with a new array, that triggers a render, and the effect fetches again. Because <code>convertPaymentMethods</code> returns a new array every time, it never stops.</p>
<p>When I replaced fetch with a mock that responds immediately and counted calls over 300ms in Jest, four measurements gave 152, 225, 231, and 233 calls. With <code>[]</code> it is 1. The repository's tests only check whether text appears on screen, so they do not catch this repetition and all pass. Readers who typed along with the article do not have this problem; it only hits when you take the repository as is.</p>
<h3 id="the-race-that-appears-the-moment-the-country-code-becomes-a-prop"><a class="anchor" href="#the-race-that-appears-the-moment-the-country-code-becomes-a-prop">The race that appears the moment the country code becomes a prop</a></h3>
<p>The fetch effect in the article's code has no cleanup either. React's official docs, in <a href="https://react.dev/learn/synchronizing-with-effects" target="_blank" rel="noopener noreferrer">Synchronizing with Effects</a>, say that when you fetch inside an effect, an earlier response that arrives late can overwrite the latest one, so you should discard earlier responses with an <code>ignore</code> flag. The article fetches only once on mount, so there is no problem for now. But the moment you take the <code>countryCode=AU</code> in the URL as a prop, a dependency appears and the race opens up. When carrying this structure over, I would write it like this from the start.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ignore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  fetchPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(countryCode).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">methods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">ignore) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setPaymentMethods</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(methods);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { ignore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [countryCode]);</span></span></code></pre></figure>
<p>One more thing. The rounding in the article is <code>Math.floor(amount + 1)</code>, so when the amount is already an integer it rounds up by another 1. A $20 order becomes $21. The article only gives $19.80 and 3312 yen as examples, so there is no telling whether this is intended. If you carry it over into a real service, you first need to decide how it should differ from <code>Math.ceil</code>.</p>
<h2 id="when-to-use-this-structure"><a class="anchor" href="#when-to-use-this-structure">When to use this structure</a></h2>
<p>The author himself says that splitting is not a fixed rule. A small, cohesive component is easier to understand when kept in one file, and what to watch out for is a file growing too large to understand. He notes that a Todo app or a single-form app is fine with everything in the component, and that the example was made complex on purpose to show many patterns. Other sources add caveats in the same direction.</p>
<p>In the same layering article, Fowler says to apply this separation only to relatively small units. Having view, model, and data as top-level folders is fine in a small system, but if any one of them gets too large, split the top level by domain and then split each of those into layers. Carry the <code>components/</code>, <code>hooks/</code>, and <code>models/</code> folders we saw earlier over as the top-level structure of a large app, and you end up with exactly the shape Fowler says to avoid.</p>
<p>Dan Abramov has a similar history. In 2015 he proposed splitting into <a href="https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0" target="_blank" rel="noopener noreferrer">presentational and container components</a>, and in 2019 he added an update at the top saying he no longer recommends splitting that way. The reason was that Hooks do the same job without an arbitrary division, and he added that he had seen it enforced far too often, dogmatically and without need. The hook extraction in the original is close to what this update describes. There is no separate component named container; <code>Payment</code>, which calls the hooks, plays that role as well.</p>
<p>On fetch, React's official docs go one step further. <a href="https://react.dev/learn/you-might-not-need-an-effect" target="_blank" rel="noopener noreferrer">You Might Not Need an Effect</a> says that extracting fetch logic into a custom hook makes it easier to move to a better strategy later, yet as the first choice it recommends a framework's built-in fetching or a client cache like TanStack Query. If you extract fetch into a plain function as in the original's last stage, you can pass that function straight to TanStack Query's <code>queryFn</code>, and the library takes over the race and repeated calls from the previous section. I see this as the most practical gain, one the original mentions in just a single sentence. How keys and functions are bound together I covered separately in <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a>.</p>
<p>To sum up, here is my judgment. The original's order (hook, pure component, domain object, Strategy, network client) is worth following as is, because at each stage the pain of that stage becomes the reason for the next. But you do not have to go all the way. Look at which stage the pain you feel right now belongs to, and stop there. If there is a lot of code unrelated to render, go as far as hooks; if the same decision is scattered around, as far as domain objects; if one change has you editing several files, as far as Strategy. And if you use Strategy, decide first where the instances are created, and if you keep fetch in an effect, write the cleanup and dependencies first. These two things the original left out were the first places to break when I actually carried it over.</p>
<p>There is no single right answer, but I hope you, the reader, will take a moment to check which stage's pain your own code is going through right now.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer" target="_blank" rel="noopener noreferrer">Microsoft Azure Architecture Center, Anti-corruption Layer pattern</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/articles/headless-component.html" target="_blank" rel="noopener noreferrer">Juntao Qiu, Headless Component</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/articles/data-fetch-spa.html" target="_blank" rel="noopener noreferrer">Juntao Qiu, Data Fetching Patterns in Single-Page Applications</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
            <category>Architecture</category>
        </item>
        <item>
            <title><![CDATA[Decision Models, Jev and Kev]]></title>
            <link>https://hooninedev.com/en/260922</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260922</guid>
            <pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about Jev, a model TypeSafe AI released last week that returns probabilities instead of text. This is for developers who want to put a threshold on a model's returned prob...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about Jev, a model TypeSafe AI released last week that returns probabilities instead of text. This is for developers who want to put a threshold on a model's returned probability and handle cases automatically without a human check. By the end, you will know how Jev's <code>confidence</code> is calculated, how much calibration moves with the distribution, and a procedure for setting the threshold on your own data.</p>
<p>The first use that caught my eye was browser automation. Browserbase opened a <a href="https://github.com/browserbase/stagehand/pull/2953" target="_blank" rel="noopener noreferrer">PR</a> that wires Jev into Stagehand's <code>act()</code>. The page's <span class="glossary-term-source" data-glossary-key="accessibility-tree">accessibility tree</span> goes in as <code>state</code>, and the question "which element should be clicked next" is asked as a set of options. Across 40 tasks the median <code>act()</code> latency dropped from 1.97 seconds to 0.46, and of 147 actions only 4 fell back to an LLM. The spot where a Playwright script breaks the moment one selector changes was filled with a call that returns a single probability. As I write this, the PR has not been merged.</p>
<p><picture><source type="image/webp" srcset="/content/260922/1.720w.webp 720w, /content/260922/1.1440w.webp 1440w, /content/260922/1.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/1.png" alt="How Stagehand act() uses Jev. A candidate list is built from the instruction and the accessibility tree, Jev is asked which one and whether none applies, the element is clicked when the none probability is low, and the step falls back to an LLM when it is high. 143 of 147 actions finished with Jev" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>The line that PR drew is the question of this post. It accepts Jev's answer at a <a href="https://www.browserbase.com/blog/what-is-jev" target="_blank" rel="noopener noreferrer">probability of 0.7</a> and hands anything below that to an LLM. <strong>Can you trust the probabilities that model returns and draw a line in your code?</strong></p>
<p>Someone else's benchmark cannot settle that, so I want to verify it on my own data. This blog has a gate that blocks transliterated loanwords. The rule says to write <code>피커</code> as <code>picker</code>, but the regular expressions only catch the half that is certain, and words that sit inside a compound or are already settled in Korean, like <code>멀티스레드</code> or <code>콜 스택</code>, I judge by hand every time. There are only two answers, revert or keep, anyone who knows decides in a second, and the pass or fail has to be decided inside a build script with no human checking. It has the same shape as Stagehand's selector decision.</p>
<p>So the order is this. What Jev returns, what kind of training produces that probability, what numbers come out when I hand it my gate, and where those numbers can be used.</p>
<h2 id="a-model-that-produces-no-text"><a class="anchor" href="#a-model-that-produces-no-text">A model that produces no text</a></h2>
<p>The release date was September 15, 2026. The company does not call it an LLM; it calls it a System One Model, a new category. In this post I call them decision models. Founder Diogo Almeida was the fourth author of the <a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">InstructGPT paper</a> at OpenAI. He was on the team that built the method that became ChatGPT's direct ancestor, and he now stands on the side that points out that method's limits.</p>
<p>Jev does not generate sentences. You fix the shape of the answer in advance, and it picks from that shape and returns probabilities along with it. There are only three question types.</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>What it asks</th>
<th>What it returns</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>choice</code></td>
<td>Which one of these is it</td>
<td>The choice, <code>probabilities</code>, <code>confidence</code></td>
</tr>
<tr>
<td><code>score</code></td>
<td>At what level is it</td>
<td>The score, <code>legend</code>, <code>probabilities</code>, <code>confidence</code></td>
</tr>
<tr>
<td><code>noul</code></td>
<td>Is it true</td>
<td>A single probability between 0 and 1</td>
</tr>
</tbody>
</table>
<p>There is only one endpoint, too. Send a <code>POST /v1/systemone</code> with <code>state</code>, the thing to evaluate, <code>model</code>, and <code>questions</code>, a map of questions, and the answer comes back. You can pack several questions about the same <code>state</code> into one request, and adding questions barely increases the response time.</p>
<p>Why it is fast is explained by the asymmetry between <span class="glossary-term-source" data-glossary-key="prefill">prefill</span> and decode that I laid out in <a href="/260610" target="_blank" rel="noopener noreferrer">how tokens work</a>. An LLM is slow because it squeezes its output out one token at a time, sequentially, and Jev has no decode stage. It reads the input once in parallel and reads the probabilities straight off. So there is no output token charge at all, and input alone costs $0.042 per million tokens.</p>
<p>A developer named Archer Hume called the API roughly ten thousand times and reconstructed its behavior from the outside in an <a href="https://archerhume.com/posts/jevs-architecture-unmasked/" target="_blank" rel="noopener noreferrer">analysis</a>. The median was 57.5ms for 360 input tokens, 218ms for 29,835 tokens, and 610ms when he packed in 1,500 questions. The most direct piece of evidence there is his observation that a response with 200 choices came back as fast as one with 2. It means writing the answer takes no time.</p>
<h2 id="decisions-nobody-watches"><a class="anchor" href="#decisions-nobody-watches">Decisions nobody watches</a></h2>
<p>The speed and the price stand out, but the question Almeida raised in the launch post pointed elsewhere. Models have been superhuman at conversation for years now, so where did all the automation go?</p>
<p>His answer is that the two kinds of work are different. Chatbots, copilots, and coding agents have the goal of satisfying a person watching from beside you. A judgment that runs quietly on a server with nobody watching does not. If you call the former assistant and the latter automation, every model released so far was built for the former.</p>
<p>I drew a similar distinction while writing up <a href="/260622" target="_blank" rel="noopener noreferrer">harness design</a>. An agent system contains countless judgments nobody looks at. Should this request be handed to a human? Is this command safe to run? Right now we put all of them to an expensive LLM. The other half that my regular expression gate cannot cover is that kind of judgment too.</p>
<h2 id="what-rlhf-left-behind"><a class="anchor" href="#what-rlhf-left-behind">What RLHF left behind</a></h2>
<p>But why did this call for a new training method? Could you not simply make an existing model answer yes or no?</p>
<p>The clue lies in <span class="glossary-term-source" data-glossary-key="calibration">calibration</span>. If accuracy is what percentage you get right, calibration is whether you know what percentage you will get right, and the metric for that gap is <span class="glossary-term-source" data-glossary-key="ece">ECE</span> (expected calibration error). It averages the difference between the stated probability and the actual hit rate in each probability bin, weighted by sample share, so 0 is perfect. Yet there is a case where this scale drifted after <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span> (reinforcement learning from human feedback), which optimizes human preference. In Figure 8 of the <a href="https://arxiv.org/abs/2303.08774" target="_blank" rel="noopener noreferrer">GPT-4 technical report</a>, the pre-trained model's ECE was <strong>0.007</strong>, while the post-trained model's was <strong>0.074</strong>, more than ten times worse. The report does not state the cause, and I have not found a primary source that establishes one either. I wrote up the definitions and how much of the cause is known separately in <a href="/260917" target="_blank" rel="noopener noreferrer">LLM Calibration and Overconfidence</a>.</p>
<p><strong>ECE alone is not enough, though.</strong> A constant predictor that stamps 0.6 on every input also has an ECE of 0 as long as its real hit rate is 60%. If the probabilities are merely honest and do not separate from case to case, there is nowhere to draw a line. So alongside calibration you have to watch <strong>whether the probabilities actually separate</strong>, and the "share that can be automated inside an error budget" I use later on is the metric that folds those two into one number.</p>
<p>From a software point of view, this is the part that matters. If the probabilities are honest and they separate, you can draw a line. You get a structure where anything above 0.95 is handled automatically and anything below goes to a person.</p>
<p>TypeSafe says it opened a third road aimed at that spot: RLHF, which optimizes human preference; RLVR (reinforcement learning with verifiable rewards), which optimizes answers a machine can grade; and RLCD, which it says optimizes calibrated decisions.</p>
<p>What has actually been published as RLCD, though, is three lines of output contract. As of September 23, 2026, when I am writing this, there is no paper or technical report anywhere in TypeSafe's documentation or launch post. There is a separate <a href="https://arxiv.org/abs/2307.12950" target="_blank" rel="noopener noreferrer">ICLR 2024 paper</a> using the same acronym, but that one is reinforcement learning from contrastive distillation, a different method. Take care not to mix them up when searching.</p>
<h2 id="what-confidence-really-is"><a class="anchor" href="#what-confidence-really-is">What confidence really is</a></h2>
<p>So, are the numbers Jev returns honest? Before answering, there is something to look at first. <strong>It does not return one number.</strong></p>
<p><picture><source type="image/webp" srcset="/content/260922/3.720w.webp 720w, /content/260922/3.1440w.webp 1440w, /content/260922/3.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/3.png" alt="A diagram showing that in a Jev response probabilities are produced by training while confidence is that distribution folded by arithmetic, and that the calibration claim attaches only to probabilities" style="max-width:min(720px, 100%);height:auto;" width="1560" height="840" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p><code>Choice</code> and <code>Score</code> responses carry both <code>probabilities</code> and <code>confidence</code>. The first is a probability distribution across all the options, the second a single number between 0 and 1. When you set a threshold in code, the one your hand reaches for first is <code>confidence</code>.</p>
<p>This value is not something the model produced. <code>_utils/confidence_metrics.py</code> in <a href="https://github.com/typesafe-ai/system-one-adapter-python" target="_blank" rel="noopener noreferrer">system-one-adapter-python</a>, published by the TypeSafe organization, is 32 lines in total, and the <code>Choice</code> part looks like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="github-dark github-light"><code data-language="python" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">def</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> choice_confidence</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probs: list[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">float</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) -> </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">float</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">:</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">    """Scale peak choice probability from uniform to certainty."""</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> len</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probs) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1.0</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    normalized_probs </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> _normalize(probs)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    uniform_probability </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1.0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> /</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> len</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized_probs)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized_probs) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> uniform_probability) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1.0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> uniform_probability)</span></span></code></pre></figure>
<p>There is no model call and no learned parameter. One <code>probabilities</code> vector goes in and one real number comes out. Written as a formula it is <code>c = (p_max − 1/K) / (1 − 1/K)</code>, and with three options and a peak probability of 0.8 it comes to 0.7.</p>
<p>This repository is not the production Jev server but a substitute implementation that imitates the same API with an LLM. So this alone cannot settle it. There are, however, two further pieces of evidence pointing at the same formula. The <a href="https://docs.typesafe.ai/confidence" target="_blank" rel="noopener noreferrer">confidence page</a> of TypeSafe's official documentation describes this value as "a statistic computed from the probability distribution" and, in its demo code, gives <code>(3 × largest probability − 1) / 2</code> as an approximation for three options. With K at 3, that is the formula above. And a <a href="https://github.com/SamuelSacco/jev-exploration" target="_blank" rel="noopener noreferrer">claims audit ledger</a> recorded that on the real API, when the winner of a response given only two options that were both wrong was 0.52, <code>confidence</code> came back as 0.04. With a K of 2, <code>(0.52 − 0.5) / (1 − 0.5) = 0.04</code>. It matches the formula.</p>
<p>In the same repository, <code>Score</code> uses an entirely different formula. It divides the mean absolute distance from the modal level by the same quantity for a uniform distribution and then <strong>subtracts the result from 1</strong>. The documentation does not publish the formula for <code>Score</code>. So there are two formulas under the single name <code>confidence</code>, and with a different type the same number means something different. <code>Noul</code> having no <code>confidence</code> is for the same reason. There is no distribution to fold.</p>
<p><picture><source type="image/webp" srcset="/content/260922/4.720w.webp 720w, /content/260922/4.1440w.webp 1440w, /content/260922/4.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/4.png" alt="Diagram of the p_max scale being stretched into the confidence scale, pulling 1/K to 0 and 1 to 1. With three options 0.80 lands on 0.7, everything below 1/3 is discarded, and the order is unchanged" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The documentation does not hide this either. It goes as far as telling you that if you want a different calculation it will hand you the whole <code>probabilities</code> and you can do as you like. <strong>It is written down, and the problem is on the side that uses it without reading.</strong></p>
<p>The comparison table in the <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev" target="_blank" rel="noopener noreferrer">launch post</a>, however, puts the two right next to each other. It says "Calibrated: higher confidence means higher accuracy." The reader takes that to mean <code>confidence</code> is the calibrated value, whereas the side the calibration claim actually attaches to is <code>probabilities</code>.</p>
<p>There is a measurement of how far this distinction opens up in numbers. An <a href="https://github.com/scienthoon/jev-ood-calibration" target="_blank" rel="noopener noreferrer">independent calibration measurement</a> published by a developer called <code>scienthoon</code> separately measured the ECE you get when you read <code>confidence</code> as "the probability of being right." It is 0.035 on OpenBookQA, 0.078 on HellaSwag, and 0.18 on a synthetic set. The raw probabilities on the same sets were 0.024, 0.029, and 0.107 respectively. <strong>On all three sets the <code>confidence</code> side is worse.</strong></p>
<p>Why it gets worse is in the formula. With K fixed, <code>c</code> is a monotonically increasing transform of <code>p_max</code>, so the ordering is preserved. Discriminative power stays the same, and you only rewrite the threshold. <strong>What breaks is not the information but the scale.</strong> If K differs from question to question, one more problem appears. The stretching factor differs too. The same <code>p_max</code> of 0.8 becomes 0.6 with two options and 0.75 with five. The moment you use a single threshold on a workload with mixed option counts, that becomes a problem.</p>
<h2 id="calibration-that-shifts-with-the-distribution"><a class="anchor" href="#calibration-that-shifts-with-the-distribution">Calibration that shifts with the distribution</a></h2>
<p>So can the <code>probabilities</code> side be trusted?</p>
<p>There is a published record of someone actually calling Jev and measuring. The repository for <a href="https://github.com/jaredpalmer/kev" target="_blank" rel="noopener noreferrer">Kev</a>, an open source reimplementation built by Jared Palmer, has a <code>runs/jev-*</code> directory, and <code>usage.json</code> records with <code>"model": "typesafe-ai/jev"</code> that these were real calls made through the Vercel AI Gateway. Adding up the six <code>jev-*</code> directories' <code>usage.json</code> files, across September 18 and 20, 2026, it made 5,364 calls on 2,418,260 input tokens. Of the seven sets below, <code>scienthoon</code> was imported by converting that developer's result files, so it is not in this total.</p>
<p>That the pricing really charges for input only is confirmed elsewhere. The author of the phishing benchmark looked at the TypeSafe dashboard and wrote it down. Of the 4.56 million tokens across two runs, 3.66 million were input and 900,000 were output, and the bill was $0.15, which matched the figure you get by counting input alone.</p>
<p>Pulling the metrics for seven evaluation sets out of <code>report.json</code> in the same directory gives this. Below, <strong>peak probability is not the <code>confidence</code> field but the maximum of <code>probabilities</code></strong>. Overconfidence is <code>평균 확신도 − 정확도</code>, mean peak probability minus accuracy, over 5,743 items in total. The values come from the <code>clean</code> block, which excludes from accuracy scoring the "unknowable" items whose grounds for judgment were erased.</p>
<table>
<thead>
<tr>
<th>Evaluation set</th>
<th>n</th>
<th>Accuracy</th>
<th>Mean peak probability</th>
<th>Overconfidence</th>
<th>ECE</th>
</tr>
</thead>
<tbody>
<tr>
<td>semif</td>
<td>144</td>
<td>0.965</td>
<td>0.965</td>
<td>-0.000</td>
<td>0.011</td>
</tr>
<tr>
<td>transfer-v9</td>
<td>1,046</td>
<td>0.854</td>
<td>0.880</td>
<td>+0.026</td>
<td>0.034</td>
</tr>
<tr>
<td>transfer-v4</td>
<td>656</td>
<td>0.857</td>
<td>0.903</td>
<td>+0.046</td>
<td>0.049</td>
</tr>
<tr>
<td>transfer-v2</td>
<td>560</td>
<td>0.855</td>
<td>0.900</td>
<td>+0.045</td>
<td>0.055</td>
</tr>
<tr>
<td>decision-v4</td>
<td>1,264</td>
<td>0.845</td>
<td>0.906</td>
<td>+0.061</td>
<td>0.067</td>
</tr>
<tr>
<td>decision-v2</td>
<td>1,200</td>
<td>0.833</td>
<td>0.904</td>
<td>+0.071</td>
<td>0.074</td>
</tr>
<tr>
<td>scienthoon</td>
<td>873</td>
<td>0.753</td>
<td>0.851</td>
<td>+0.099</td>
<td>0.105</td>
</tr>
</tbody>
</table>
<p>Two things read out of it.</p>
<p>First, <strong>the ECE moves by nearly ten times.</strong> From 0.011 to 0.105. Same scorer, same model. "Calibrated" is not a property of the model but a relation between the model and the distribution. That calibration collapses when the distribution shifts is itself a known result. What is new here is that <strong>the same shape appears even in a model that puts calibration forward as its training objective</strong>.</p>
<p>The 0.105 of the set with the largest ECE is bigger even than the 0.074 of the post-RLHF GPT-4 seen earlier. But you must not read these two as a ranking. The tasks differ, and ECE is an estimator whose value moves with how many bins you split into and which bins the samples pile up in, so placing numbers from different scorers side by side does not make a valid comparison.</p>
<p>Second, <strong>the measured ECE is almost entirely explained by overconfidence alone.</strong> The difference between the two columns runs from 0.002 to 0.011 for all seven. ECE is a weighted average of per-bin gaps, so it cannot be smaller than the overall overconfidence, and that difference being nearly 0 means <strong>the sign of the error in each bin leans one way</strong>. It is not noise that crosses over and cancels bin by bin.</p>
<p>Reading down the columns shows why. Leave out <code>semif</code> and six of the mean peak probabilities sit packed between 0.851 and 0.906. Over the same stretch accuracy moves twice as wide, from 0.753 to 0.857. <strong>Peak probability barely moves when the distribution changes, and only accuracy moves. The difference left over is the ECE.</strong></p>
<p><picture><source type="image/webp" srcset="/content/260922/5.720w.webp 720w, /content/260922/5.1440w.webp 1440w, /content/260922/5.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/5.png" alt="Chart of seven Jev evaluation sets from real API calls: mean confidence clusters between 0.851 and 0.906 while accuracy alone moves from 0.753 to 0.965, and the gap nearly equals ECE" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Another benchmark shows how this property surfaces in practice. In a <a href="https://github.com/anisselbd/jev-phishing-bench" target="_blank" rel="noopener noreferrer">measurement</a> on 2,000 phishing emails with Claude Haiku 4.5 as the control, Jev's accuracy was 62.6% and Haiku's was 81.3%. More striking, though, is the ECE. The repository reports an ECE of 0.154 for Jev and 0.097 for Haiku, and when the claims audit ledger re-bins Jev's <code>P(phishing)</code> values into 10 bins from 0 to 1, it comes to 0.170. Read either way, <strong>a model that puts calibration forward as its training objective lost on calibration to an LLM built with RLHF.</strong> The same author got 91.6% from the link host list rule alone.</p>
<p>Looking at the same metric across several sets makes the spread plainer. The share that can be handled automatically at a 5% <span class="glossary-term-source" data-glossary-key="error-budget">error budget</span> is 0.486 on the <code>scienthoon</code> set, 0.695 on <code>transfer-v9</code>, and 1.000 on <code>semif</code>. Kev's scorer notes that this value is the maximum with the threshold chosen inside the sample, not an error guarantee after deployment. <strong>You must not cite any one of them as if it were the model's spec.</strong></p>
<h2 id="the-transliteration-gate"><a class="anchor" href="#the-transliteration-gate">The transliteration gate</a></h2>
<p>Reading someone else's benchmark and measuring on my own data are different things. So I put the gate from the opening through it for real.</p>
<p>I do not have a Jev API key yet. Instead I <strong>ran Kev-9B locally</strong>, the reimplementation mentioned above. It puts a rank-16 <span class="glossary-term-source" data-glossary-key="lora">LoRA</span> and a pointer head on Qwen3.5-9B-Base, and it is Apache-2.0. The numbers below are not Jev's numbers. Kev's training data is English decision tasks, and in the same author's measurements Kev-9B trails Jev even on English tasks. Its automation share at a 5% error budget is 0.45 to 0.57 against Jev's 0.70, and on MMLU-Pro it is 0.52 against 0.84. The TypeSafe documentation also states, about Jev, that <a href="https://docs.typesafe.ai/models" target="_blank" rel="noopener noreferrer">English is the primary training language and CJK is not equivalent</a>.</p>
<p>The evaluation sets were drawn from 18 Korean posts in this repository. The ground truth for a label is <strong>which spelling this repository consistently uses for that word</strong>. That means it is this blog's convention rather than a consensus of the Korean technical writing community, and on words where the two diverge the model can be right by the community standard and wrong by this one.</p>
<ul>
<li><strong>The gate set, 102 sentences</strong>: These are words the gate already knows. The positives are 23 <strong>counterfactual sentences</strong> in which words the body writes in English, such as <code>calendar</code>, <code>picker</code>, and <code>adapter</code>, were turned back into Korean transliterations; the negatives are 79 sentences that actually use the exceptions <code>write-post.md</code> spells out, such as <code>리렌더링</code> and <code>콜 스택</code>. On this set the current regular expression gate is right 100% of the time by definition.</li>
<li><strong>The held-out set, 60 sentences</strong>: <strong>These are words the gate has never seen.</strong> This is what machine learning calls a <span class="glossary-term-source" data-glossary-key="held-out-set">held-out</span> set. The positives are 30 sentences that turn <code>loader</code>, <code>mutation</code>, and <code>prefill</code>, which the body writes only in English, back into transliterations; the negatives are 30 sentences with <code>리듀서</code>, <code>스냅샷</code>, and <code>런타임</code>, which the body writes only in Korean. Here the regular expressions catch none of the positives.</li>
</ul>
<p><picture><source type="image/webp" srcset="/content/260922/6.720w.webp 720w, /content/260922/6.1440w.webp 1440w, /content/260922/6.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/6.png" alt="Diagram of the 102-sentence gate set and 60-sentence held-out set, their positive and negative composition, and how the regex gate covers the gate set completely but catches none of the held-out positives" style="max-width:min(720px, 100%);height:auto;" width="1560" height="860" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Let me disclose up front the asymmetry that the positives are counterfactual sentences I built while the negatives are real sentences. And <strong>the effective sample is the number of words, not the number of sentences.</strong> Labels are decided per word, so sentences containing the same word are not independent observations. The gate set has 24 distinct words and the held-out set 13.</p>
<p>The table below tallies only the one <code>noul</code> question asking "should this word be turned back into English." I put four questions in the same request, but the other three were a negated form, a <code>Choice</code> variant, and a compound-word judgment whose labels I could not set properly, so they are not mixed in here. The <strong>5% budget automation share</strong> is the largest share you get by cutting down from the highest peak probability such that the error above the cut does not exceed 5%.</p>
<table>
<thead>
<tr>
<th></th>
<th>Gate set</th>
<th>Held-out set</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sentences / distinct words</td>
<td>102 / 24</td>
<td>60 / 13</td>
</tr>
<tr>
<td>Current regex gate</td>
<td><strong>1.000</strong></td>
<td>0.500</td>
</tr>
<tr>
<td>Kev-9B accuracy</td>
<td>0.225</td>
<td>0.500</td>
</tr>
<tr>
<td>Word-level accuracy</td>
<td>6/24</td>
<td>7/13</td>
</tr>
<tr>
<td>Majority class baseline</td>
<td>0.775</td>
<td>0.500</td>
</tr>
<tr>
<td>ECE</td>
<td>0.664</td>
<td>0.336</td>
</tr>
<tr>
<td>Mean peak probability</td>
<td>0.876</td>
<td>0.821</td>
</tr>
<tr>
<td>Share at peak probability 0.9 or above</td>
<td>0.490</td>
<td>0.317</td>
</tr>
<tr>
<td>Actual hit rate in that band</td>
<td>0.240</td>
<td>0.526</td>
</tr>
<tr>
<td><strong>5% budget automation share</strong></td>
<td><strong>0.010</strong></td>
<td><strong>0.000</strong></td>
</tr>
</tbody>
</table>
<p>The majority class baseline is the score you get by never reading the sentence and stamping the more common label. The model sits below it.</p>
<p>The last row is this experiment's answer. <strong>On the held-out set, no matter how high I set the threshold, there was not a single decision that could be handled automatically within a 5% error.</strong></p>
<p>The prediction distribution shows what happened. The model answered <strong>"it should be turned back" on all 162 sentences.</strong> All 37 distinct words. Every positive right and every negative wrong. So the 0.500 on the held-out set is not skill; it is a number that comes from the set being balanced.</p>
<p>And while doing that, its mean peak probability was 0.876. On the gate set, 49% came in above a peak probability of 0.9, and the actual hit rate in that band is 0.240.</p>
<p>The structural identity did not hold either. Adding the probabilities for "should it be turned back" and "should it be left as is" gave a mean of 1.655 on the gate set, and all 102 sentences deviated from 1 by more than 0.1.</p>
<p>That is by design. The two questions are separate evaluations that cannot read each other, and TypeSafe itself put an example on its <a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" target="_blank" rel="noopener noreferrer">jaggedness page</a> where <code>refund</code> at 0.72 and <code>not_refund</code> at 0.47 add up to 1.19.</p>
<p>Here is where it bites in practice. <strong>You must not move a threshold tuned for <code>Noul</code> over to <code>Choice</code>.</strong> For the same judgment, 14% to 17% of the sentences came out with different conclusions.</p>
<p>If the model says yes to whatever you ask, this result is meaningless. That is what I checked first.</p>
<table>
<thead>
<tr>
<th>State / question</th>
<th>What I asked</th>
<th>Answer</th>
</tr>
</thead>
<tbody>
<tr>
<td>Korean / Korean</td>
<td>이 문장은 영어로 쓰여 있는가? (Is this sentence written in English?)</td>
<td>0.02</td>
</tr>
<tr>
<td>Korean / Korean</td>
<td>이 문장은 요리법을 설명하는가? (Does this sentence describe a recipe?)</td>
<td>0.04</td>
</tr>
<tr>
<td>Korean / Korean</td>
<td>이 문장은 한국어로 쓰여 있는가? (Is this sentence written in Korean?)</td>
<td>0.98</td>
</tr>
<tr>
<td>Korean / English</td>
<td>Is this sentence written in English?</td>
<td>0.01</td>
</tr>
<tr>
<td>English / English</td>
<td>Is this sentence about software?</td>
<td>0.96</td>
</tr>
</tbody>
</table>
<p><strong>It reads Korean, it says "no," and it separates 0.02 from 0.98.</strong> This is not a global acquiescence bias. That is as far as this rules anything out. The possibility that the wording of the gate question is itself bad remains.</p>
<h2 id="putting-the-rules-into-state"><a class="anchor" href="#putting-the-rules-into-state">Putting the rules into state</a></h2>
<p>Would it change, then, if I handed it the knowledge the judgment needs? This is the domain adaptation method the TypeSafe documentation recommends: leave the weights alone and ship the reference material inside <code>state</code>.</p>
<p>I took the rules paragraph already written in <code>write-post.md</code>, put it into <code>state</code> alongside the sentence, and ran it again. The input grew from 100 tokens per sentence to 450. <strong>The gate set is <span class="glossary-term-source" data-glossary-key="data-leakage">leakage</span>.</strong> That reference material lists the gate words and the exception words by name. So that side is a control for "does it read the reference material at all," and the real test is the held-out set.</p>
<table>
<thead>
<tr>
<th></th>
<th>Gate, leaked</th>
<th>Gate + rules</th>
<th>Held-out</th>
<th>Held-out + rules</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>noul</code> accuracy</td>
<td>0.225</td>
<td>0.676</td>
<td><strong>0.500</strong></td>
<td><strong>0.500</strong></td>
</tr>
<tr>
<td>Word-level accuracy</td>
<td>6/24</td>
<td></td>
<td>7/13</td>
<td><strong>5/13</strong></td>
</tr>
<tr>
<td>Positives correct</td>
<td>23/23</td>
<td>1/23</td>
<td>30/30</td>
<td>8/30</td>
</tr>
<tr>
<td>Negatives correct</td>
<td>0/79</td>
<td>68/79</td>
<td>0/30</td>
<td>22/30</td>
</tr>
<tr>
<td>5% budget automation share</td>
<td>0.010</td>
<td>0.314</td>
<td>0.000</td>
<td>0.050</td>
</tr>
<tr>
<td>Median latency</td>
<td>2,924ms</td>
<td>6,903ms</td>
<td>2,908ms</td>
<td>6,918ms</td>
</tr>
</tbody>
</table>
<p>The latency is local inference run on an M2 Max, so it is not on the same axis as the Jev API latencies seen earlier. What to look at here is not the absolute value but the 2.4x when the reference material is added.</p>
<p><strong>On the balanced held-out set, sentence-level accuracy went from 0.500 to 0.500, not moving at all.</strong> At the word level it actually went down, from 7/13 to 5/13.</p>
<p><picture><source type="image/webp" srcset="/content/260922/7.720w.webp 720w, /content/260922/7.1440w.webp 1440w, /content/260922/7.1560w.webp 1560w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260922/7.png" alt="Before and after adding the rule paragraph to state, on the 60 held-out sentences. Before, all 30 revert sentences were right and all 30 keep sentences wrong; after, 8 and 22 were right. The total correct is 30 both times" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The 0.225 to 0.676 on the gate set is not an improvement in skill. Before, it said "turn it back" on all 102 sentences; once given the reference material it answered that way on only 12. Negatives climbed from 0/79 to 68/79, but positives collapsed from 23/23 to 1/23. That set has more negatives, 79 against 23, so a constant that says "mostly leave it" automatically scores higher. <strong>It did not learn the distinction; it changed which way it leans.</strong></p>
<p>That does not mean the model is not reading the sentence. For the same sentences containing the same word, the predictions scattered sentence by sentence once the reference material was given. The standard deviation for <code>로더</code> grew from 0.096 to 0.217, and <code>뮤테이션</code> from 0.042 to 0.236. <strong>It reads, but it cannot distinguish.</strong></p>
<p>And this result also shakes the previous section's reading. If a single piece of state text flips the output wholesale, then the "turn everything back" of the first run may be a shortcoming of the question's wording rather than a limit of the model. This post has not ruled that possibility out.</p>
<p>One thing improved noticeably. The sum of the probabilities for "turn it back" and "leave it" closed from 1.508 to 0.963 on the held-out set, and the sentences deviating far from 1 fell from 59 to 18. <strong>Yet the accuracy did not move.</strong> Two questions' probabilities agreeing with each other and those probabilities being right are different things.</p>
<h2 id="where-decision-models-belong"><a class="anchor" href="#where-decision-models-belong">Where decision models belong</a></h2>
<p>Reading this far, it is easy to lean toward the conclusion that this whole model category is useless, but lining up the published benchmarks says otherwise.</p>
<table>
<thead>
<tr>
<th>Task</th>
<th>n</th>
<th>Jev</th>
<th>Comparison</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://github.com/bitnovus/jev-spam-eval" target="_blank" rel="noopener noreferrer">Spam, in distribution</a></td>
<td>18,514</td>
<td>98.3%</td>
<td>TF-IDF regression 98.4%</td>
</tr>
<tr>
<td>Spam, out of distribution</td>
<td>2,876</td>
<td><strong>98.6%</strong></td>
<td>TF-IDF regression <strong>73.0%</strong></td>
</tr>
<tr>
<td>Phishing</td>
<td>2,000</td>
<td>62.6%</td>
<td>Haiku 4.5 81.3%, rule baseline 91.6%</td>
</tr>
<tr>
<td><a href="https://github.com/anessbelbati/jev-rerank-bench" target="_blank" rel="noopener noreferrer">Rerank, 8 English sets</a></td>
<td>1,617</td>
<td>nDCG@10 0.692</td>
<td>Cohere Rerank 4 Pro 0.691</td>
</tr>
</tbody>
</table>
<p>Only the rerank row is a search ranking quality metric, so it sits on a different axis from the accuracies in the other rows.</p>
<p><strong>Inside the distribution, a regular expression or a fitted classifier wins or ties.</strong> It is the same place where the regular expressions beat the model 1.000 to 0.225 on my gate set. If you can collect labels, training a small model is cheaper, faster, and more accurate. That is something we have been doing for a long time.</p>
<p><strong>The gap opens when the distribution is new and there are no labels.</strong> On <span class="glossary-term-source" data-glossary-key="out-of-distribution">out-of-distribution</span> spam it is 98.6% against 73.0%. The fitted classifier collapses in front of a shape it has never seen, while this side holds. The place for decision models is <strong>judgments you cannot collect data for, label, and train on</strong>. It is where every situation is new, so labels cannot be gathered, yet the judgment has to be made within seconds.</p>
<p>This frame also explains why my transliteration judgment failed. What that judgment needs is not general reasoning but <strong>the specific domain knowledge of which words have settled in Korean technical writing</strong>, and for a 9B reimplementation trained on English decision tasks, that is not out of distribution so much as knowledge it simply does not have. That is why giving it the rules in prose did not work either.</p>
<p>In a TechCrunch article, Armin Ronacher, the CTO of Earendil, which builds the Pi harness, <a href="https://techcrunch.com/2026/09/18/a-new-kind-of-ai-model-from-a-chatgpt-inventor-is-thrilling-developers/" target="_blank" rel="noopener noreferrer">put it this way</a>.</p>
<blockquote>
<p>At the end of the day, it delegates the hallucination problem a little bit to the user.</p>
</blockquote>
<p>It has not removed hallucination; it has handed the judgment to the developer. The TypeSafe documentation also writes that calibration is a property that holds over a batch of predictions, not a guarantee that an individual answer is right, and under the 0% hallucination bar in the launch post, the Nuance entry reads "Our number is not empirical." The form is guaranteed and the content is not.</p>
<h2 id="the-194-projects-on-jevable"><a class="anchor" href="#the-194-projects-on-jevable">The 194 projects on jevable</a></h2>
<p>Since it failed on my data, I looked at where other people are using it. <a href="https://jevable.com/" target="_blank" rel="noopener noreferrer">jevable.com</a> is an independent curation by a developer named Nikunj, who reviews Jev projects posted on X and collects them. As of September 23, 2026 it lists 194, and the registration dates cluster between September 16 and 20. 152 of them arrived on September 18 alone, three days after the release.</p>
<table>
<thead>
<tr>
<th>Category</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr>
<td>Games</td>
<td>39</td>
</tr>
<tr>
<td>Developer tools</td>
<td>34</td>
</tr>
<tr>
<td>Productivity</td>
<td>31</td>
</tr>
<tr>
<td>Agents</td>
<td>19</td>
</tr>
<tr>
<td>Experiments</td>
<td>18</td>
</tr>
<tr>
<td>Creative tools</td>
<td>16</td>
</tr>
<tr>
<td>Data &#x26; research, Finance, Browser extensions, Robotics, Marketing</td>
<td>37</td>
</tr>
</tbody>
</table>
<p>Regrouping by "what is being asked" rather than by category gives three shapes. Every figure below is what the posters wrote in their own posts.</p>
<p><strong>First, classification and routing.</strong> Sorting 1,500 emails, classifying tax documents, classifying 1,891 competitor ads in 19 seconds for $0.12, 14 typed checks on a single PR diff, spotting ads among Android notifications, classifying 26 construction drawings in 2.9 seconds. This is the same shape as the loanword gate at the start of this post. As the table in the previous section said, these are places where labels pile up every day, so given time a small classifier trained on your own labels wins or ties. Jev's advantage is on day one, when there are no labels.</p>
<p><strong>Second, action selection loops.</strong> A browser agent that hooked into Browser Use and finished a flight search in 7 seconds for $0.0039, computer use that drives a Mac by voice, a game that forks the VM four ways every time Mario dies and picks the branch that survives, an expression engine that decides ten things per message for a 3D character, including its mouth, eyebrows, and gaze. The action space changes at every step, so you cannot collect labels, and the judgment has to come in under a second. This is the same place where the gap opened up on out-of-distribution spam. I think it is also why Games, at 39, is the largest category. A game is an action selection loop where being wrong can be undone.</p>
<p><strong>Third, UI that shows the probability to the user.</strong> Ask Jev, which returns only a verdict instead of an answer; JevForm, a branching form that picks the next question by probability; Upweight, which re-sorts the Hacker News front page with six sliders such as technical depth and drama. Since no threshold is hard-coded and a person reads the probability, these have the lowest calibration requirement of anything this post has taken issue with.</p>
<p>One thing stands out. Of the 194 blurbs, 30 mention cost and 53 mention speed, but only 7 mention accuracy or a baseline. This is a lower bound because the blurbs only carry the start of each post, but the direction is clear. Fast and cheap you can know on day one; whether it is right you can only know by measuring, and the measuring side is rare. One of those 7 is <code>jevcal</code>. It says everyone picks thresholds by feel, and given your data and a target accuracy it returns a threshold and an automation rate. It is the procedure the next section recommends, turned into a tool.</p>
<h2 id="how-to-draw-the-line"><a class="anchor" href="#how-to-draw-the-line">How to draw the line</a></h2>
<p>This post's conclusion is this. <strong>Do not read the probabilities a model returns as a spec; measure on your own data and draw the line yourself.</strong></p>
<p>The measuring procedure goes like this. Gather 100 or so labeled items, build a table of the actual hit rate per probability bin, and lower the threshold from the top while watching the error rate above it. Once you have set an error budget, the maximum coverage that keeps that budget is the share you can automate. In my experiment, the fact that the actual hit rate above a peak probability of 0.9 was 0.240 was exposed by 102 sentences.</p>
<p><strong>A scale that is off can usually be fixed.</strong> If the overconfidence is spread evenly across all the bins, a single temperature straightens out most of it. Kev did exactly that and brought its ECE down from 0.106 to 0.042. The catch is that fitting that temperature requires labels from that distribution, and labels are precisely what you do not have in production. On top of that, temperature cannot change the ordering of the probabilities, so the automatable share does not rise by the same amount. Which is why ordering matters more than scale, and error budget coverage is a more practical metric than ECE.</p>
<p>Had I hardcoded the 0.9 the documentation uses in its example, this gate would have quietly mis-corrected 38 items. No error is raised, so it is a failure that is hard to notice. The TypeSafe documentation does say, right below that example, to test it on your own data. What ends up hardcoded is usually the number above, not the sentence below.</p>
<p>Even without a key, the measuring part you can do today. Kev is published under Apache-2.0 and runs on a laptop.</p>
<p>I would like you to count how many calls in the service you run ask a big model nothing but yes or no. Before you move those calls to a decision model, I would suggest measuring where the line has to go after the move. I kept that order, and that is why I could decide to leave the gate alone. When I get a Jev key I plan to run the same sets again, and if the conclusion changes then, I will write that down too.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2411.15124" target="_blank" rel="noopener noreferrer">Lambert et al., Tulu 3: naming RLVR</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/themsquared/jev-benchmark" target="_blank" rel="noopener noreferrer">themsquared/jev-benchmark, judging tool call risk</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Calibration</category>
        </item>
        <item>
            <title><![CDATA[overlay-kit]]></title>
            <link>https://hooninedev.com/en/260921</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260921</guid>
            <pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about interfaces that treat overlays declaratively. I like declarative code. When it comes to opening an overlay, closing it, and doing asynchronous work inside it, I thin...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about interfaces that treat overlays declaratively.</p>
<p>I like declarative code. When it comes to opening an overlay, closing it, and doing asynchronous work inside it, I think the overlay itself should manage that sequence. When the calling side holds <code>isOpen</code> and flips it on and off, the code that handles the state ends up in a different place from the component that renders it.</p>
<p>The modals on the screens I owned at work were exactly the opposite. Whether a modal was open lived in a global store, the call site opened it, and the call site closed it. The call site was also writing the code that closed the modal and the callbacks that received its result. So I spent a long time working out how to move this in a declarative direction, and in the end I rewrote it.</p>
<p>While doing that work I looked at <a href="https://github.com/toss/overlay-kit" target="_blank" rel="noopener noreferrer">overlay-kit</a>. It was close to the interface I was aiming for. This post is a record of overlay-kit's declarative interface and of how I designed our internal modal system.</p>
<h2 id="the-modal-i-was-holding-in-state"><a class="anchor" href="#the-modal-i-was-holding-in-state">The modal I was holding in state</a></h2>
<p>In my post on <a href="/260518" target="_blank" rel="noopener noreferrer">state management</a>, I used a modal's open flag as the simplest example of local state. I wrote that it is state used inside a single component, state that the outside has no need and no right to know about. I still think that is true. But once you have dozens of modal types, they stack on top of one another, and they have to return a value to the call site, that state does not stay inside the component.</p>
<p>The code I inherited kept the list of open modals in a global store. Since it is company code, the example below keeps the structure and changes the identifiers.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">openPopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useModalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useShallow</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [state.openPopup, state.closePopup]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> handleAddNote</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  openPopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    itemId,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onClose</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">note</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      closePopup</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(PopupType.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      saveNote</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(note);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [closePopup, openPopup, itemId]);</span></span></code></pre></figure>
<p>Three things about this code bothered me.</p>
<p><strong>First, the opening function has to come out of a hook.</strong> <code>openPopup</code> and <code>closePopup</code> are values you select from the store, so you can only obtain them inside a component. That means the code that opens a modal cannot leave the component. To open a modal somewhere you cannot use hooks, such as an API response interceptor or a router guard, you have to pass that function down as an argument.</p>
<p><strong>Second, the result comes back through callbacks.</strong> Whether the note was saved or the user simply closed the dialog arrives through two separate branches, <code>onSubmit</code> and <code>onClose</code>. Anything you want to do after opening the modal goes inside a callback. If you have to open two modals in a row, you get a callback inside a callback.</p>
<p><strong>Third, closing is the call site's responsibility.</strong> The call site is the one that calls <code>closePopup(PopupType.ADD_NOTE)</code>. The modal does not close itself; the side that opened it remembers which kind of modal it was and closes it. That is why the same constant appears three times in one function.</p>
<p>What the three have in common is that opening a single modal requires the call site to write three kinds of code: the code that opens it, the callback that receives the result, and the code that closes it.</p>
<p>If deciding what to show is the call site's job, then here the call site was carrying everything after that as well. In particular, <code>closePopup(PopupType.ADD_NOTE)</code> names its target with a string constant. Writing another modal's constant still passes type checking because it belongs to the same enum, and the wrong modal closes.</p>
<h2 id="an-interface-that-waits-for-a-result"><a class="anchor" href="#an-interface-that-waits-for-a-result">An interface that waits for a result</a></h2>
<p>So which of those three can be removed? The answer I arrived at was to make opening a modal a function call, and to have that function return a Promise carrying the result.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> openModalAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ADD_NOTE'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { itemId });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (result.reason </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'confirmed'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> saveNote</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(result.value);</span></span></code></pre></figure>
<p>Three things changed. The opening function became a module function rather than a hook, so it can be called outside a component. The result became a return value rather than a callback argument, so the follow-up work stayed inside the flow of the calling function. And closing became the modal component's own job.</p>
<p>Because the return type is <code>Promise&#x3C;ModalResult&#x3C;V>></code>, the end of a modal collapsed into a single value.</p>
<p>A Promise settles only once. Resolving an already settled Promise does nothing, so every reason a modal can end has to fit into that one value. That is why I split <code>reason</code> into five cases: the user confirmed, the user explicitly cancelled, it closed without a value, it was replaced by a new modal of the same kind, or it was force-closed by a full teardown. With a single boolean, "cancelled" and "the whole screen was torn down" become the same value, and flows that need cleanup have to tell those two apart.</p>
<p>In the other direction, if you never settle it, the call site waits forever. Nothing happens when a callback is not called, but an <code>await</code> simply never returns. So every time a new path for removing a modal from the screen appeared, I had to check whether that path also settled the Promise. Replacement and force-close each got a <code>reason</code> of their own for that reason.</p>
<h2 id="overlay-kits-call-site"><a class="anchor" href="#overlay-kits-call-site">overlay-kit's call site</a></h2>
<p>The call site in toss's overlay-kit looks like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> confirmed</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">openAsync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ConfirmDialog</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{isOpen}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onConfirm</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    onCancel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span></code></pre></figure>
<p>Three points struck me as a good interface.</p>
<p><strong>What opens is visible at the call site.</strong> My version passes a string key, <code>'ADD_NOTE'</code>. Which component that key refers to is known to a registry file: a file containing one object that pairs each string key with the <code>import()</code> function that loads that modal.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> modalImporters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  ADD_NOTE</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'.../add-note-modal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  CONFIRM</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'.../confirm-modal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 모달 종류만큼 이어진다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>overlay-kit writes the JSX right there. Anyone reading the code knows what will appear without navigating to that file.</p>
<p><strong>The result type is decided at the call site.</strong> The type argument of <code>openAsync&#x3C;boolean></code> is both the argument type of <code>close</code> and the result type of the <code>await</code>. You do not have to declare a result type per modal and register it in a table.</p>
<p><strong>Closing and removing are separate.</strong> The controller receives four props: <code>overlayId</code>, <code>isOpen</code>, <code>close</code>, and <code>unmount</code>. <code>close</code> sets <code>isOpen</code> to <code>false</code> and leaves the component in place. <code>unmount</code> is what actually removes it. <a href="https://overlay-kit.slash.page/ko/docs/guides/introduction" target="_blank" rel="noopener noreferrer">The official guide to opening and closing overlays</a> explains it this way.</p>
<blockquote>
<p>The reason for this difference is that when you use <code>close</code>, the overlay is kept in memory so that its closing animation can play.</p>
</blockquote>
<p>My version has no such distinction. Closing removes it from the list immediately. That means I was leaving closing animations to the component library, and that I was only dealing with screens where that was possible.</p>
<h2 id="from-overlayopen-to-render"><a class="anchor" href="#from-overlayopen-to-render">From overlay.open to render</a></h2>
<p>Once you like an interface, the next question is what is inside it. For a single module function to render a component inside the React tree, something has to bridge the two.</p>
<h3 id="events-and-the-reducer"><a class="anchor" href="#events-and-the-reducer">Events and the reducer</a></h3>
<p><code>overlay.open</code> does not change state. It emits one event.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/event.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayControllerComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">options</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OpenOverlayOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> overlayId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> options?.overlayId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> randomId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> componentKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> randomId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dispatchOpenEvent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'open'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  dispatchOpenEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ controller, overlayId, componentKey });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlayId;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>The receiving side is <code>OverlayProvider</code>. The provider holds a <code>useReducer</code>, subscribes to the event, and feeds <code>ADD</code> into the reducer. The thing to watch here is that <code>isOpen</code> goes in as <code>false</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/provider/index.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">overlayState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useReducer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(overlayReducer, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  current: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  overlayOrderList: [],</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  overlayData: {},</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> open</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'open'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlayId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">componentKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ADD'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    overlay: { id: overlayId, componentKey, isOpen: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, isMounted: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, controller },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, []);</span></span></code></pre></figure>
<p>Then, on the frame after the controller mounts, it dispatches <code>OPEN</code> to turn <code>isOpen</code> into <code>true</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/provider/content-overlay-controller.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    overlayDispatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'OPEN'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, overlayId });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [overlayDispatch, overlayId]);</span></span></code></pre></figure>
<p>One part here was not what I expected.</p>
<p><strong>The state does not live outside React.</strong> Because the API opens overlays through a module function, I assumed the store would sit at module level too, but it is actually a <code>useReducer</code> inside the provider. The only thing outside React is the event bus, and that holds no state. Overlays render as siblings next to the provider's <code>children</code>. It does not use <code>createPortal</code>, and it does not import <code>react-dom</code>.</p>
<p>One more thing stands out. From the call site it is one line, <code>overlay.open()</code>, but inside it is a fixed procedure: emit an event, feed <code>ADD</code> into the reducer, wait a frame, and feed <code>OPEN</code> in again. A declarative interface does not remove imperative code; it moves it to the other side of a boundary.</p>
<h3 id="the-opening-side-and-the-closing-side"><a class="anchor" href="#the-opening-side-and-the-closing-side">The opening side and the closing side</a></h3>
<p>The <code>requestAnimationFrame</code> above and the split between <code>close</code> and <code>unmount</code> from the previous section look unrelated at first. In fact they are two devices blocking the two ends of the same constraint.</p>
<p>That constraint is that a CSS transition only runs when <strong>both the start state and the end state are actually rendered</strong>. <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions" target="_blank" rel="noopener noreferrer">MDN's CSS transitions guide</a> warns about elements just added to the DOM.</p>
<blockquote>
<p>This is treated as if the initial state had never occurred and the element was always in its final state.</p>
</blockquote>
<p>The failure shows up in two shapes. On opening, there is no start state, so nothing runs. On closing, the element disappears before the end state is painted, so nothing runs.</p>
<table>
<thead>
<tr>
<th></th>
<th>Opening</th>
<th>Closing</th>
</tr>
</thead>
<tbody>
<tr>
<td>Problem</td>
<td>There is no start state</td>
<td>It disappears before the end state is painted</td>
</tr>
<tr>
<td>Solution</td>
<td>Mount it closed first, then open it on the next frame</td>
<td>Keep it mounted, flip only <code>isOpen</code>, remove it later</td>
</tr>
<tr>
<td>Mechanism</td>
<td><code>ADD(isOpen: false)</code>, rAF, <code>OPEN</code></td>
<td><code>CLOSE</code>, animation, <code>REMOVE</code></td>
</tr>
<tr>
<td>Who knows the timing</td>
<td>The library</td>
<td>The user</td>
</tr>
</tbody>
</table>
<p>The last row explains why the closing side is left to the user. On the opening side, the library knows the constant: one frame is enough. On the closing side, the library cannot know whether the animation is 200 or 400 milliseconds.</p>
<h4 id="closing-handed-over-as-unmount"><a class="anchor" href="#closing-handed-over-as-unmount">Closing handed over as <code>unmount</code></a></h4>
<p>The reducer is what makes the closing side work. <code>CLOSE</code> only flips, inside <code>overlayData</code>, the <code>isOpen</code> value, and leaves <code>overlayOrderList</code> alone. The provider renders by walking that list, so the component stays alive as long as it remains in the list. It only unmounts once <code>REMOVE</code> takes it out.</p>
<p><strong>That said, rAF is not always necessary.</strong> react-transition-group <a href="https://reactcommunity.org/react-transition-group/transition" target="_blank" rel="noopener noreferrer">does not run the enter transition on first mount by default</a>. It skips it even when <code>in</code> is already <code>true</code>, and you have to turn on <code>appear</code>. overlay-kit's rAF changes <code>isOpen</code> from <code>false</code> to <code>true</code> after mount, which covers that case.</p>
<h3 id="twenty-lines-wrapped-in-a-promise"><a class="anchor" href="#twenty-lines-wrapped-in-a-promise">Twenty lines wrapped in a Promise</a></h3>
<p><code>openAsync</code> is not a separate implementation. It is a thin layer around <code>open</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/event.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> openAsync</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">controller</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayAsyncControllerComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">options</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OpenOverlayOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_resolve</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_reject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlayProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">deprecatedLegacyContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> close</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">param</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        _resolve</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(param);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        overlayProps.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> reject</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reason</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">        _reject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(reason);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        overlayProps.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OverlayAsyncControllerProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlayProps, close, reject };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> controller</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(props, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">deprecatedLegacyContext);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }, options);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>All it does is swap the <code>close</code> that goes to the controller. The original <code>close</code> takes no arguments; this one takes an argument, resolves the Promise with it, and then calls the original <code>close</code>. This is also why the result type is decided at the call site. <code>T</code> is both the argument type of <code>close</code> and the type of the Promise.</p>
<p>There is one hole in this structure, though. Resolution only happens when <code>close</code> is called. So if you remove an overlay with <code>unmount</code> alone, the Promise stays pending forever. It is filed as <a href="https://github.com/toss/overlay-kit/issues/169" target="_blank" rel="noopener noreferrer">issue #169</a>, and I think it needs fixing, but the issue is still open.</p>
<p>The fact that resolution happens at <code>close</code> carries another implication. It is the moment the closing starts, not the moment the closing animation ends. That is fine for a flow that takes a confirmation and then calls an API, but in a flow that takes a confirmation and then opens the next modal, the second modal rises while the first is still disappearing.</p>
<h3 id="where-the-design-came-full-circle"><a class="anchor" href="#where-the-design-came-full-circle">Where the design came full circle</a></h3>
<p>I followed the commit history to explore how the design got here, and found that the structure was not like this from the start. 1.0 used the same event and reducer combination as today. 1.2.0 moved the state into a module-level external store read through <code>useSyncExternalStore</code>, and then 1.8.0 moved it back to a reducer.</p>
<p>The reason for reverting is written in <a href="https://github.com/toss/overlay-kit/issues/148" target="_blank" rel="noopener noreferrer">issue #148</a>.</p>
<blockquote>
<p><code>useSyncExternalStore</code> resolves tearing issues, but it doesn't work well with suspense.</p>
</blockquote>
<p>When fetching data inside an overlay caused it to suspend, React warned that a component had suspended while responding to synchronous input. The original goal of moving to an external store was to let overlay state be read without a hook, and the PR at the time says outright that the goal was not achieved the intended way. In the end the goal was solved with a hook, and only the storage mechanism returned to where it started.</p>
<p>What I took from this record is not a conclusion but a criterion. Putting state outside React frees up where you can call things, at the cost of opening room to fall out of step with React's scheduling. overlay-kit put only the call site outside and kept the state inside.</p>
<h2 id="reopening-with-the-same-id"><a class="anchor" href="#reopening-with-the-same-id">Reopening with the same id</a></h2>
<p>Something caught my eye while reading the reducer. At the very top of <code>ADD</code> there is this branch.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/src/context/reducer.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">case</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'ADD'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state.overlayData[</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] != </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; state.overlayData[</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">overlay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">].isOpen === </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> overlay</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state.overlayData[action.overlay.id];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (overlay </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> overlay.isOpen) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      current: action.overlay.id,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      overlayData: { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state.overlayData, [action.overlay.id]: { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay, isOpen: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span></code></pre></figure>
<p>If you open an overlay with an explicit <code>overlayId</code>, <code>close</code> it, and then open it again with the same id, you land in this branch. But the return value reuses the <strong>existing <code>overlay</code> object</strong>.</p>
<p>Nothing ever uses the incoming <code>action.overlay</code> and its <code>controller</code>.</p>
<p>Reading alone did not convince me, so I cloned the library's repository and attached a test myself. I prepared two components that render different content and opened them in order under the same id.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ControllerA</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> data-testid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>AAA&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ControllerB</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> data-testid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>BBB&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(ControllerA, { overlayId: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(ControllerB, { overlayId: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'id-close'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span></code></pre></figure>
<p>The results were as follows, run with vitest on the main branch at overlay-kit 1.9.0.</p>
<table>
<thead>
<tr>
<th>Procedure</th>
<th>What rendered</th>
</tr>
</thead>
<tbody>
<tr>
<td>Open A, <code>close</code>, reopen with B</td>
<td><code>AAA</code>. B is discarded</td>
</tr>
<tr>
<td>Open A, <code>unmount</code>, reopen with B</td>
<td><code>BBB</code>. Replaced correctly</td>
</tr>
<tr>
<td>Increment a counter to 1, <code>close</code>, reopen</td>
<td><code>1</code>. Component state is preserved</td>
</tr>
</tbody>
</table>
<p>The third row is behavior the docs describe as a feature, so it was as expected. The problem is the first row.</p>
<h3 id="the-new-jsx-is-ignored"><a class="anchor" href="#the-new-jsx-is-ignored">The new JSX is ignored</a></h3>
<p>I checked one more thing here. Further down in the same reducer there is this comment.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/**</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> * </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">@description</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> Brings the overlay to the front when reopened after closing without unmounting.</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"> */</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">overlayOrderList</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">state.overlayOrderList.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">item</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> item </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> action.overlay.id), action.overlay.id],</span></span></code></pre></figure>
<p>It says that reopening after closing brings the overlay to the front. But because the earlier branch hits <code>return</code> first, this line does not run in that situation. I stacked two overlays, ran <code>close</code> on the one underneath, reopened it, and the DOM order did not change.</p>
<h3 id="behavior-that-contradicts-the-comment"><a class="anchor" href="#behavior-that-contradicts-the-comment">Behavior that contradicts the comment</a></h3>
<p>I did not confirm whether this shows up as a visible problem on screen, though. The test only looked at DOM order, and since most dialog libraries use their own z-index or portals, DOM order is not the same as visual order.</p>
<p>My version exposes the same situation as an option. When the same kind of modal is opened again, the call site chooses whether to keep the existing one or replace it with new props. Choosing replacement settles the existing Promise as <code>replaced</code>. This is one of the reasons I split the result type into five branches earlier.</p>
<p><strong>This is where the two designs diverge.</strong> My approach treats modals as data. Because the string key and the props live in a store, they can be replaced or updated later. overlay-kit treats modals as functions. The controller is a closure, so once it is stored there is no way to change its contents from outside.</p>
<h2 id="the-price-of-jsx-at-the-call-site"><a class="anchor" href="#the-price-of-jsx-at-the-call-site">The price of JSX at the call site</a></h2>
<p>So is writing JSX at the call site always better than using a string key? I do not think so. There are three prices to pay.</p>
<p><strong>Code splitting drops out of the default.</strong> Writing JSX at the call site means the call site imports that component. If the modal is a heavy editor screen and only a few users press that button, its weight goes into the bundle of the list screen.</p>
<p><code>React.lazy</code> does work inside a controller. I measured it the same way as the earlier test: the module was not loaded before <code>open</code>, and when opened, the <code>Suspense</code> fallback appeared first and then the body rendered.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> Heavy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lazy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'./HeavyModal'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">overlay.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">open</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> isOpen </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}>&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Heavy</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClose</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{close} />&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>So the difference is not whether it is possible but what the default is.</p>
<p>In my version every value in the registry file is an <code>import()</code> function, so code splitting is structurally enforced. With overlay-kit you have to write it by hand at each call site.</p>
<p><strong>The user has to decide where to call <code>unmount</code>.</strong> If you only call <code>close</code> and stop there, the overlay's information stays in memory. The official docs warn about this and give separate guidance for where to hook <code>unmount</code> in each design system. MUI and Mantine have a callback for when the transition ends, so you can hook it there, but for libraries without such a callback the docs tell you to set a timer matching the animation length. The fact that the guidance is split per library is itself the cost of this decision.</p>
<p><strong>Overlays render where the provider is.</strong> Because it does not use portals, the same code runs on React Native, which is an advantage. Conversely, context provided below <code>OverlayProvider</code> is not visible inside an overlay. When opening a modal that relies on a router or form context, check this point first.</p>
<h2 id="how-far-the-word-declarative-reaches"><a class="anchor" href="#how-far-the-word-declarative-reaches">How far the word declarative reaches</a></h2>
<p>Having written this far, one thing nags at me. I started this post calling this interface declarative, yet <code>overlay.openAsync(...)</code> is a function call by any measure.</p>
<p>Let us start from the definition. <a href="https://react.dev/learn/reacting-to-input-with-state" target="_blank" rel="noopener noreferrer">The React documentation</a> draws the line this way.</p>
<blockquote>
<p>In React, you don't directly manipulate the UI--meaning you don't enable, disable, show, or hide components directly. Instead, you <strong>declare what you want to show,</strong> and React figures out how to update the UI.</p>
</blockquote>
<p>The analogy in the same document is sharper. Imperative is calling out every turn to the person driving the car; declarative is getting into a taxi and naming your destination.</p>
<p><strong>A declarative interface does not remove the imperative procedure. It moves it to the other side of a boundary and gives that boundary a name.</strong> The inside of overlay-kit we saw earlier is exactly that. The call site is one line, while inside, a procedure emits an event and dispatches twice.</p>
<p>So let us look at where each project applies the word. overlay-kit's docs call the previous approach imperative. What they point to is code that holds the open flag with <code>useState</code> and flips it in an event handler. What they call declarative is the side that treats an overlay as an action rather than as state. The toss tech blog's <a href="https://toss.tech/article/frontend-declarative-code" target="_blank" rel="noopener noreferrer">article on declarative code</a> defines the word more broadly: declarative code is code at a higher level of abstraction, and a hook that abstracts the act of showing an overlay is one example.</p>
<p>So I decided to narrow the word this way. <strong>The call site does not write the closing code.</strong> When to close, and what remains on screen while it closes, are decided by the overlay; the call site writes only what to show and what it expects to receive back.</p>
<p>By this criterion, code written with <code>useState</code> that holds <code>isOpen</code>, and code that keeps a list of open modals in a global store while the call site closes them, are on the same side. Only the storage location differs; in both, the call site writes the closing statement.</p>
<h2 id="closing-thoughts"><a class="anchor" href="#closing-thoughts">Closing thoughts</a></h2>
<p>What I fixed in our company's code was not, in the end, where the state was stored. It was who writes the closing code.</p>
<p>Even when we used a global store, the state was already outside the component, but the statements that opened and closed it were still at the call site. Only after switching to a single <code>await</code> did those statements move inside the modal.</p>
<p>overlay-kit reached the same place and then went one step further: it also puts what to show at the call site. I think that interface reads well. And while writing this, I learned what that one step costs.</p>
<p><strong>It was the difference between treating a modal as a function and treating it as data.</strong> Writing JSX at the call site makes the controller a closure, and a closure's contents cannot be changed from outside once it is stored. That structure is where the ignored JSX on reopening with the same id comes from. Treating it with a string key makes the modal a value in a store that you can replace, update, and code split, but the call site only ever learns a name.</p>
<p>I think the feeling that you like a library's interface is a fairly reliable signal. Still, you should put into words at least once what exactly that feeling points at. Without doing that, you cannot tell whether what you liked was the interface or the reputation of the people using the library. I would encourage readers to do that exercise once for a library they are using right now.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://overlay-kit.slash.page/" target="_blank" rel="noopener noreferrer">overlay-kit official documentation</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/desko27/react-call" target="_blank" rel="noopener noreferrer">desko27/react-call</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
            <category>Library</category>
        </item>
        <item>
            <title><![CDATA[LLM Calibration and Overconfidence]]></title>
            <link>https://hooninedev.com/en/260917</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260917</guid>
            <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about model calibration and the overconfidence that shows up after RLHF. This is for developers who want to use the probability or confidence a model returns as a decision...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about model calibration and the overconfidence that shows up after RLHF. This is for developers who want to use the probability or confidence a model returns as a decision criterion in code. By the end, you will be able to explain how accuracy and calibration differ, what ECE measures, how the probabilities of a model polished on human preference drift from its actual hit rate, and how much of the cause is actually known.</p>
<p>I had to sort out these concepts first while testing the claims of Jev, a decision model released by TypeSafe AI. Instead of sentences, Jev returns a probability for each option, and it puts calibration forward as its training objective. To weigh that claim, I first needed to know what calibration is and why it drifts in existing models.</p>
<h2 id="accuracy-and-calibration"><a class="anchor" href="#accuracy-and-calibration">Accuracy and calibration</a></h2>
<p>First you have to separate accuracy from <span class="glossary-term-source" data-glossary-key="calibration">calibration</span>. Accuracy is what percentage you get right; calibration is whether you know what percentage you will get right. If you gather only the days a forecast said a 70% chance of rain and it actually rained seven times out of ten, that forecast is well calibrated. That does not mean it is accurate. It means it knows its own limits. <strong>A model that gets only 60% right scores full marks on calibration if it says 60% about itself.</strong></p>
<p>The scale can drift in two directions. When the probability a model states is higher than its actual hit rate, that is overconfidence; when it is lower, underconfidence. Put the stated probability on the horizontal axis and the actual hit rate of each probability bin on the vertical axis, and you can see at a glance where the two split. A plot drawn this way is called a reliability diagram.</p>
<p><picture><source type="image/webp" srcset="/content/260917/1.720w.webp 720w, /content/260917/1.1440w.webp 1440w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260917/1.png" alt="The horizontal axis is confidence, the probability the model states, and the vertical axis is accuracy, the actual hit rate. The dashed diagonal is perfect calibration; the orange curve sagging below it is overconfidence, and the region above the diagonal is underconfidence" style="max-width:min(720px, 100%);height:auto;" width="1440" height="1200" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>A curve on the diagonal means the model got right as often as it said. The further it sags below the diagonal, the less it gets right relative to the probability it states.</p>
<p>The metric for that gap is <span class="glossary-term-source" data-glossary-key="ece">ECE</span> (expected calibration error). Following the definition in <a href="https://arxiv.org/abs/1706.04599" target="_blank" rel="noopener noreferrer">Guo et al. 2017</a>, for each probability bin it takes the difference between the "stated probability" and the "actual hit rate," weights it by that bin's share of the samples, and averages; 0 is perfect. The code below transcribes that formula. It compares a model that gets 6 of 10 questions right when it says 0.6 every time and when it says 0.95 every time.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Guo et al. 2017 의 식 (3). 구간은 등간격 10개(논문 실험은 15개), 경계는 왼쪽 닫힘</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">preds</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">M</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> bins</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ length: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">M</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ n: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, hit: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, conf: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">correct</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> preds) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bins[Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">min</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">M</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(p </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> M</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.hit </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> correct </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    b.conf </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> p</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bins.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sum</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> preds.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">abs</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.hit </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.n </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.conf </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.n) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum),</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 10문제 중 6문제를 맞힌다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> answers</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ length: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ correct: i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 6</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(answers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">a, p: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.6</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toFixed</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 매번 0.6 이라고 말할 때</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ece</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(answers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">a, p: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.95</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }))).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toFixed</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 매번 0.95 라고 말할 때</span></span></code></pre></figure>
<p>This is the result of running it with Node v24.16.0 on 2026-10-08.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>0.000</span></span>
<span data-line=""><span>0.350</span></span></code></pre></figure>
<p>Accuracy is 0.6 in both cases. Only the stated probability changed, yet ECE rises from 0 to 0.35. But because the formula takes an absolute value, ECE measures only the size of the gap, not its direction. A model that says 0.25 every time and still gets 6 questions right also gets an ECE of exactly 0.35. Whether it is overconfidence or underconfidence, you have to look at the reliability diagram or at average confidence minus accuracy.</p>
<h2 id="the-rlhf-objective"><a class="anchor" href="#the-rlhf-objective">The RLHF objective</a></h2>
<p>Then why does a model polished with human feedback drift off this scale? First we need to see what <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span> (reinforcement learning from human feedback) optimizes. The skeleton of building a reward model out of human preference comparisons came from <a href="https://arxiv.org/abs/1706.03741" target="_blank" rel="noopener noreferrer">Christiano et al. 2017</a>, an early carry-over to language models was <a href="https://arxiv.org/abs/1909.08593" target="_blank" rel="noopener noreferrer">Ziegler et al. 2019</a>, and <a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">InstructGPT</a> applied it to instruction following. The value pushed up is <strong>the score of a reward model that imitates human preference</strong>. The language-model work adds to the reward a KL penalty that keeps the model from straying too far from where it stood before reinforcement learning began, and InstructGPT also mixed in gradients from the pretraining data (PPO-ptx), but what it pushes up is still that score.</p>
<p>For a chatbot, human preference is the right objective. The trouble is that this preference leans away from wording that reveals uncertainty. <a href="https://arxiv.org/abs/2401.06730" target="_blank" rel="noopener noreferrer">Zhou et al. 2024</a> showed, on four public preference datasets, that annotators chose answers containing weakeners such as "I'm not sure, maybe" less often. The difference was small but significant. They did not, however, choose answers with strengthened, emphatic certainty more often. <a href="https://arxiv.org/abs/2410.09724" target="_blank" rel="noopener noreferrer">Leng et al. 2025</a> showed that reward models give higher scores to answers that state a high confidence score regardless of their actual quality. Both results concern expressions of confidence in text (verbalized confidence), not token probabilities. TypeSafe, which offers a training method meant to replace RLHF, also notes in its own <a href="https://docs.typesafe.ai/introduction/machine-learning-primer" target="_blank" rel="noopener noreferrer">primer</a> that RLHF can reward confident-sounding hallucinations. That is the position of a vendor selling the alternative.</p>
<h2 id="gpt-4s-calibration-curves"><a class="anchor" href="#gpt-4s-calibration-curves">GPT-4's calibration curves</a></h2>
<p>A drift in the same direction has been observed in token probabilities too. Figure 8 in the main body of the <a href="https://arxiv.org/pdf/2303.08774v6#page=12" target="_blank" rel="noopener noreferrer">GPT-4 technical report</a> places the reliability diagrams of the pre-trained model and the post-trained model side by side, and the caption reads:</p>
<blockquote>
<p>Right: Calibration plot of the post-trained GPT-4 model on the same subset of MMLU. The post-training hurts calibration significantly.</p>
</blockquote>
<p>The horizontal axis is the probability (logprob) the model assigned to each of the choices A, B, C, and D on MMLU multiple-choice questions, and the vertical axis is the actual hit rate in that bin. By the numbers printed on Figure 8, the pre-trained model's ECE is <strong>0.007</strong> and that of the model tuned with PPO (the reinforcement learning algorithm used in RLHF to update the model toward higher reward-model scores) is <strong>0.074</strong>. More than ten times worse.</p>
<p>In the right-hand (PPO) panel of Figure 8, bars at 0.4 and above sit below the diagonal, but the bins between 0.1 and 0.3 actually sit above it. The bars between 0.1 and 0.9 are bunched flat at an actual hit rate of roughly 0.3 to 0.5, so as the probability the model states goes up, the hit rate barely follows. Those underconfident bins are mixed into the 0.074 as well. And as Guo et al. point out, a reliability diagram does not show how many samples fall in each bin, so the distance of a bar from the diagonal cannot be assumed proportional to the share that bin contributes to ECE.</p>
<p>This panel differs from the concept diagram above in two ways. First, it does not just sag below the diagonal like the curve in the concept diagram; it crosses the diagonal once. Second, it puts the probabilities of all four choices into the bins, so it also differs from the definition in the code above, which measures the probability of the single chosen answer. So I do not compare 0.074 with the 0.35 from the code above on the same scale.</p>
<p>So why did it get worse? The report's main text says only that the pre-trained model was well calibrated and that calibration was reduced after post-training; it does not state a cause. A clue lies in Anthropic's <a href="https://arxiv.org/abs/2207.05221" target="_blank" rel="noopener noreferrer">Kadavath et al. 2022</a>. The RLHF policies they trained from their own language models looked very poorly calibrated at face value. The paper called this unsurprising, since RL fine-tuning tends to collapse predictions toward the behaviors that receive the most reward. It was a quick side experiment, not the paper's main subject. Yet applying a single temperature, T=2.5, to every evaluation largely fixed the calibration problems on three evaluations. Temperature is a value that divides the logits; when it is greater than 1, it leaves the order of answers intact and only flattens the probability distribution. I read the fact that one value largely fixed it as meaning the drift was not concentrated in a few bins but was the whole distribution narrowed to one side. The paper also added the caveat that more intensive RL training might distort calibration in ways that cannot be fixed like this.</p>
<p>This is a result on Anthropic's models, so it cannot be carried over directly as the cause of Figure 8. The studies in the previous section also dealt with expressions of confidence in text. I could not find a primary source that directly links the finding that people avoid uncertain wording to the drift in token probabilities.</p>
<h2 id="the-probability-your-code-receives"><a class="anchor" href="#the-probability-your-code-receives">The probability your code receives</a></h2>
<p>The probability in Figure 8 is a token log probability. To receive the same kind of value, following the <a href="https://github.com/openai/openai-openapi/blob/506aff0a8099581b50e119b87f8f2692cdad043f/openapi.yaml" target="_blank" rel="noopener noreferrer">OpenAI API specification</a>, you set <code>logprobs</code> to <code>true</code> in a Chat Completions request and use <code>top_logprobs</code> to choose, between 0 and 20, how many candidates to receive at each token position. A value you get by having the model state a confidence number along with its answer is confidence stated in words, and it is a different value from the token probability.</p>
<p>Studies of the two values compare different pairs. <a href="https://arxiv.org/abs/2305.14975" target="_blank" rel="noopener noreferrer">Tian et al. 2023</a> reported that inside RLHF models such as ChatGPT, GPT-4, and Claude, confidence stated in words was typically better calibrated than conditional probabilities, often reducing ECE by a relative 50% or so across three benchmarks. In this comparison, though, the conditional probability of models whose weights are not public was not the API's token probability but an estimate: the share of 10 samples of the same question that produced the answer. Leng et al., seen above, reported that compared with pre-RLHF models, RLHF models are more overconfident in the confidence they state in words. The two results can both hold. And 0.074 is the value the 2023 GPT-4 post-trained model produced on a subset of MMLU, so it cannot be carried over as is to the models you call through the API today.</p>
<h2 id="wrapping-up"><a class="anchor" href="#wrapping-up">Wrapping up</a></h2>
<p>To sum up, calibration is not about what percentage you get right but whether you know what percentage you will get right, and ECE is the number that measures the size of the gap. For the direction, you have to look at the reliability diagram or at average confidence minus accuracy. RLHF pushes up the score of a reward model that imitates human preference. Kadavath et al. see that optimization as tending to narrow predictions toward what earns the most reward. There is research showing that people choose answers that reveal uncertainty less often, and that reward models give higher scores to answers stating a high confidence score regardless of quality. In GPT-4, ECE went from 0.007 to 0.074 after post-training, but OpenAI did not go as far as stating the cause. Calibration also comes out differently depending on whether the probability your code receives is a token probability or confidence stated in words, so whichever it is, you need to measure it again on your own data.</p>
<p>Whether the probabilities of Jev, a decision model that puts calibration forward as its training objective, are actually honest is covered in <a href="/260922" target="_blank" rel="noopener noreferrer">Decision Models, Jev and Kev</a>.</p>
<p>Before you hard-code a model's probability as a threshold, I hope you will check at least once what that model was trained to aim for, and measure whether its scale holds on your own data.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Calibration</category>
        </item>
        <item>
            <title><![CDATA[Shared Language]]></title>
            <link>https://hooninedev.com/en/260723</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260723</guid>
            <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about communicating across functions. As a developer, I spend as much time working out what to build as I spend writing code. I exchange requirements with product managers...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about communicating across functions.</p>
<p>As a developer, I spend as much time working out what to build as I spend writing code. I exchange requirements with product managers, align on screens with designers, and ask people who know the domain what a term actually means. And I have experienced several times that the cost of getting this process wrong is far greater than the cost of writing the wrong code.</p>
<p>I recently read <a href="https://softwareleads.substack.com/p/engineering-leaders-day-to-day-activities" target="_blank" rel="noopener noreferrer">Engineering Leaders' Day-to-Day Activities</a> by James Samuel. The author divides a leader's work into six categories, and the first one he covers is gathering information. His reason is that every decision, direction, and action depends on an accurate understanding of what is happening right now.</p>
<p><picture><source type="image/webp" srcset="/content/260723/1.720w.webp 720w, /content/260723/1.1440w.webp 1440w, /content/260723/1.1951w.webp 1951w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260723/1.png" alt="Managerial activities drawn as six linked circles: information gathering, planning, leading, influencing decisions, making decisions, and information sharing" width="1951" height="1673" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>I do hope to take on a leadership role someday, but what struck me after reading that passage was slightly different. <strong>If the first thing a leader handles is gathering information, then the individual contributor's counterpart to that is understanding requirements.</strong> The way I handle requirements now will become the way I handle an organization's information later.</p>
<p>I am not trying to write about leadership here. This is about what I have been doing wrong as an individual contributor communicating across functions, and what I can use as tools. To state the conclusion first, I came to believe this is not purely a matter of attitude. There is an alternative framing available, called ubiquitous language, and it is worth looking at what that means.</p>
<h2 id="the-same-word-different-meanings"><a class="anchor" href="#the-same-word-different-meanings">The same word, different meanings</a></h2>
<p>In his article on <a href="https://martinfowler.com/bliki/BoundedContext.html" target="_blank" rel="noopener noreferrer">Bounded Context</a>, Martin Fowler gives the example of an electricity company. A large share of the misalignments I have run into took this shape.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Here the word “meter” meant subtly different things to different parts of the organization: was it the connection between the grid and a location, the grid and a customer, or the physical meter itself?</p></div><div class="quote-original" lang="en"><p>here the word 'meter' meant subtly different things to different parts of the organization: was it the connection between the grid and a location, the grid and a customer, the physical meter itself</p></div></blockquote>
<p>The same word, “meter,” meant subtly different things in different parts of the organization. And Fowler adds that this confusion is not an accident particular to that case. He says he has repeatedly seen the same confusion around polysemous words like Customer and Product.</p>
<p><strong>This did not happen because the meeting participants were bad at meetings.</strong> When an organization contains different contexts, it is natural for the same word to diverge, and if that divergence is left alone it eventually splits the code as well.</p>
<p>I covered the technical background of this story once in <a href="/260418" target="_blank" rel="noopener noreferrer">Domain Model</a>. That post was about how to express a model inside the code. This post is about the stage before that model exists, when people are still talking to each other.</p>
<h2 id="a-word-with-two-meanings"><a class="anchor" href="#a-word-with-two-meanings">A word with two meanings</a></h2>
<p>But why does this repeat structurally? There are two lines of explanation.</p>
<p>One comes from organizational structure. In <a href="https://melconway.com/Home/Committees_Paper.html" target="_blank" rel="noopener noreferrer">How Do Committees Invent?</a>, the paper Mel Conway published in Datamation in 1968, he says that to the degree an organization is unable to flexibly change its communication structure, it stamps its own image onto every design it produces. The single sentence usually quoted as Conway's Law is in fact a formulation the author himself produced later, and I find this 1968 passage more direct.</p>
<p>What I took from that sentence in practical terms is the word “stamps.” It means that diverging terminology does not end with the conversation but <strong>remains in the artifacts</strong>. The “meter” above is exactly that. If people build separately while holding different understandings of the scope of “meter,” that difference persists not in the meeting notes but in table names, API response fields, and the wording on screens. And from that point on, the cost of fixing it is nowhere near the cost of having one more conversation.</p>
<p>This is where what an individual contributor can and cannot do separates. Changing an organization's communication structure is beyond an individual contributor's reach. But <strong>making the boundaries of terminology visible</strong> is something you can do from where you sit right now. It means writing down the scope a word has in this document, and noting explicitly when it differs from the word another team uses. Every tool we look at later is a device that makes that job easier.</p>
<p>The other explanation is a bit more fundamental. Seen from cognitive science, collaboration only holds together on top of a shared background in the first place. <a href="https://web.stanford.edu/~clark/1990s/Clark,%20H.H.%20_%20Brennan,%20S.E.%20_Grounding%20in%20communication_%201991.pdf" target="_blank" rel="noopener noreferrer">Grounding in Communication</a>, written by Herbert Clark and Susan Brennan in 1991, opens with the example of a piano duet. The two performers cannot even begin to coordinate without presupposing a vast amount of shared information, that is, common ground. Common ground here means mutual knowledge, mutual beliefs, and mutual assumptions. And the authors state firmly that all collective action is built on common ground and on its accumulation.</p>
<p>Here is the perspective I arrived at after placing these two lines side by side. <strong>Diverging terminology is not an accident; it is the default state.</strong> Being aligned is the exception, the state that has to be maintained at a cost. Which makes the practical question this: who pays that cost, and how?</p>
<h2 id="active-watching"><a class="anchor" href="#active-watching">Active watching</a></h2>
<p>In Domain-Driven Design, published in 2003, Evans presents a pattern called ubiquitous language, or <strong>shared language</strong>. It is often summarized as little more than “developers and domain experts should use the same terms,” but reading the original text, the prescription is far more specific. He asks the team to use the model as the backbone of the language and to commit to using that language relentlessly in all communication within the team and in the code. And the sentence that follows was the most important part for me.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Domain experts raise objections to terms or structures that are awkward or inadequate for conveying an understanding of the domain, while developers stay alert to any ambiguity or inconsistency that will trip up the design.</p></div><div class="quote-original" lang="en"><p>Domain experts object to terms or structures that are awkward or inadequate to convey domain understanding, while developers watch for ambiguity or inconsistency that will trip up design.</p></div></blockquote>
<p>Domain experts object to terms and structures that are awkward or inadequate for conveying domain understanding, and <strong>developers watch for the ambiguity or inconsistency that will trip up the design</strong>.</p>
<p>Reading that sentence, I corrected my understanding of my own role. Until then I had regarded myself as a receiver in requirements meetings. I thought my job was to take whatever the product side decided and implement it accurately. But the developer's share as Evans defines it was not reception. <strong>It is finding ambiguity and handing it back.</strong> That is not passive cooperation; it is active watching.</p>
<p>(For reference, Fowler rendered this sentence in his own article as “Domain experts <strong>should</strong> object ... developers <strong>should</strong> watch.” The original book has no modal verb. It is a small difference, but the original reads as more definitive. The tone is not that doing so would be good advice, but that doing so is the definition of the pattern.)</p>
<p>So how do you find ambiguity? Evans answers that too. Using the terms repeatedly in conversation is what exposes differences in how they are interpreted. This is how I read that sentence: ambiguity is not something you find by reading documents closely. No matter how carefully you read a spec, the fact that “meter” means several different things will not surface. That word has to be used repeatedly against concrete cases before it splits apart. The moment someone asks, “What does everyone else understand meter to mean?” is that point.</p>
<h2 id="the-principle-of-least-collaborative-effort"><a class="anchor" href="#the-principle-of-least-collaborative-effort">The principle of least collaborative effort</a></h2>
<p>We may know this method and still fail to practice it. So why do we not ask repeatedly?</p>
<p>Let us go back to Clark and Brennan's paper. The authors give the name grounding to the process of turning what has been said into part of the common ground. And they offer one principle about how people behave during that process: the principle of least collaborative effort. It comes from the observation that people do not like to work harder than they have to.</p>
<p>Here comes the important passage.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Under the principle of least collaborative effort, people should aim to reach grounding with no more combined effort than is required. Yet which actions cost effort shifts dramatically from one communication medium to another.</p></div><div class="quote-original" lang="en"><p>By the principle of least collaborative effort, people should try to ground with as little combined effort as needed. But what takes effort changes dramatically with the communication medium.</p></div></blockquote>
<p><strong>What takes effort changes dramatically depending on the medium.</strong> A confirmation technique available in one medium may be outright impossible in another, or possible only at a much higher cost. The authors point out in particular that in media where the other party does not receive your words immediately, the cost of relying on someone else to correct a misunderstanding becomes very high, so speakers try to avoid it.</p>
<p>There is one thing to note here. This paper was written in 1991, and the media the authors compared were things like face-to-face conversation, telephone, letters, and answering machines. <strong>It does not deal with Slack or Notion.</strong> The framework that grounding cost varies by medium is theirs; applying it to today's asynchronous work channels is my interpretation.</p>
<p>Applying it that way explains why I did not ask repeatedly. In an environment where you receive a spec as text and confirm it as text, sending a confirmation message at every ambiguous point is expensive. You do not know when the answer will come, and you worry that asking several times will make you look like you did not understand. So people naturally slide into their own interpretation.</p>
<p>It is worth separating two forces here. One is that the medium raises the cost of confirmation, and that is Clark and Brennan's point. The other is the worry that asking several times will make you look incompetent, and that is not what their principle predicts but belongs to the psychological safety discussion later on. To be precise, what the principle of least collaborative effort predicts is that <strong>you will choose a cheaper way to confirm</strong>, not that you will stop confirming. Abandoning confirmation and moving on to a guess is not the principle operating; it is grounding failing. And the fact that the interpretation was wrong surfaces only after implementation is done.</p>
<h2 id="reaching-a-sufficient-level"><a class="anchor" href="#reaching-a-sufficient-level">Reaching a sufficient level</a></h2>
<p>So should you ask about every ambiguity? That is not realistic, and I have never seen anyone do it. The paper offers a standard for this question too: the grounding criterion. It refers to the state in which both parties believe the listener has understood what the speaker meant <strong>to a level sufficient for current purposes</strong>. The authors preface it with the observation that perfect understanding is impossible to begin with.</p>
<p>The reason this criterion felt practical to me is that it lowers the target. You do not need to understand requirements perfectly. <strong>It is enough to be able to mutually trust that you understand them well enough for what you are about to do.</strong> And the authors say that when the purpose changes, the criterion has to change with it.</p>
<p>In practice, this becomes the standard for calibrating how hard to press for confirmation. If you dig into “What does meter mean here?” in a session that is still exploring direction, the conversation stops moving. What is needed at that stage is agreement on what you are trying to do and why, not the settling of boundary values. Conversely, right before implementation begins, that question absolutely has to come up. At that point you need to be able to trust that both sides are picturing the same scope for “meter,” and if you cannot, code will pile up on a false premise.</p>
<p>So when I meet the same ambiguity, <strong>I handle it differently depending on which stage I am in.</strong> During exploration I write it down on a list and move on; right before implementation I open that list and close the items one by one. Deferring confirmation and abandoning confirmation are different things.</p>
<p>There is one tool that pairs well at this point. Team Topologies, by Matthew Skelton and Manuel Pais, distinguishes three modes of interaction between teams: collaboration, where two teams discover something new together for a defined period; X-as-a-Service, where one side provides and the other consumes; and facilitation, where one side helps and mentors the other.</p>
<p>It is originally a story about organizational design, but I found it practical to shrink it down to the scale of a single meeting. <strong>If the participants hold different beliefs about which mode this conversation is in, the meeting goes off the rails.</strong> If the product manager thinks this is a session for conveying what has already been decided while the developer thinks it is a session for discovering together, the developer's questions sound like objections rather than collaboration. The reverse happens too. If the developer came to receive a finalized spec while the product manager was still exploring, the developer gets frustrated that there is no spec.</p>
<p>So these days I check this at the start of a meeting: “Is this still open, or is this a session to confirm what has already been decided?” That single question changes the character of everything that follows. It is the same thing as adjusting the grounding criterion to fit the purpose.</p>
<h2 id="when-the-resolution-does-not-match-the-conversation-spins-in-place"><a class="anchor" href="#when-the-resolution-does-not-match-the-conversation-spins-in-place">When the resolution does not match, the conversation spins in place</a></h2>
<p>Even with the mode aligned, a problem remains. Another misalignment I have run into often is talking at different <strong>resolutions</strong>.</p>
<p>Requirements handed over as prose from a product manager are usually too abstract. A sentence like “so users can conveniently check their reservation history” does not say how many screens there are or what leads where. Conversely, a designer's mockup is too concrete. Button colors and spacing are already decided, which makes it hard to discuss the very question of whether this flow is right.</p>
<p>Shape Up, written by Ryan Singer at Basecamp, names this problem precisely. Starting from wireframes or concrete visual layouts traps you in unnecessary detail and prevents you from exploring as broadly as you need to. So what <a href="https://basecamp.com/shapeup/1.3-chapter-04" target="_blank" rel="noopener noreferrer">Shape Up proposes</a> is a representation in between. It is called breadboarding, a concept borrowed from electrical engineering. A breadboard is a prototype that has all the components and wiring of the real device but none of the industrial design. So there are exactly three things you draw: the places you can navigate to, the affordances the user can act on, and the connection lines that show where those actions take the user.</p>
<p>The reason I liked this technique is that it is an artifact a developer can produce. Instead of asking the product manager to write in more detail or waiting on the designer for a mockup, you can draw the flow as you currently understand it, right there, and hand it back with “this is how I understood it, is that right?” It is a concrete way of executing the watching Evans described, the handing back of ambiguity. And seen through the lens of the previous section, it is a device that lowers grounding cost. One picture stands in for several rounds of confirmation over text.</p>
<p>But this resolution is harder to hold than it sounds. As you draw the flow, you naturally slide down into things like “wouldn't this button be better in the bottom right?” I have done it several times myself. Once you slide down like that, the question that was being discussed, namely whether this flow is right, quietly disappears. If you are deciding button positions while the flow is not settled yet, all that discussion gets thrown away wholesale when the flow changes later.</p>
<p>So at this stage I try to keep the drawing <strong>deliberately bad</strong>. If it is nothing but boxes, arrows, and names, the other person does not think to point at details and responds only to the flow. Low fidelity is a feature of this tool rather than a defect. (Suppressing the urge to make it pretty is harder than it sounds.)</p>
<p>The idea that the level of abstraction can itself be a tool for collaboration connects to what I wrote in <a href="/260201" target="_blank" rel="noopener noreferrer">Abstraction</a>. That post was about levels of abstraction in code; conversations have the same thing.</p>
<h2 id="separating-rules-examples-and-questions"><a class="anchor" href="#separating-rules-examples-and-questions">Separating rules, examples, and questions</a></h2>
<p>Another way to align resolution is to structure the discussion itself.</p>
<p>Consider <a href="https://cucumber.io/blog/bdd/example-mapping-introduction/" target="_blank" rel="noopener noreferrer">Example Mapping</a>, introduced in 2015 by Matt Wynne, who led the Cucumber project. He diagnoses the reason many teams struggle with requirements discussions as the lack of structure, which makes them long and boring. And so teams end up not doing them regularly or consistently. That was precisely my situation. When a requirements meeting runs long, you try to keep the next one short, and when you keep it short the ambiguities stay in place.</p>
<p>Example Mapping splits the discussion across four colors of cards. Yellow is the story under discussion, blue is a rule or acceptance criterion, green is a concrete example that illustrates that rule, and red is a question nobody knows the answer to.</p>
<p>Of these, <strong>I think the red card is the core.</strong> The other three are organizing techniques, but the red card is a device that turns “I don't know” into an official output of the meeting. Saying that you do not know stops being an act that delays the meeting and becomes a result the meeting is supposed to produce.</p>
<p>Why that matters becomes clear if you imagine the opposite. In a meeting without red cards, running into an ambiguity leaves two options: dig in now and stretch the meeting out, or move on and interpret it alone later. As we saw in the previous section, people generally choose the latter. But with one card available, a third option appears. <strong>You write it down and move on, without letting it disappear.</strong> The meeting keeps moving, and the ambiguity remains on the list.</p>
<p>Green cards play a similar role. When only the rule is written, everyone agrees; the moment someone tries to write a concrete example illustrating that rule, interpretations diverge. It is Evans's “using the terms repeatedly in conversation exposes differences” transposed into a meeting format. If you ask people to attach three examples to a single rule, one of them will usually produce a “wait, what happens in this case?” (In my experience, a meeting where that question does not come up is not a meeting where everyone understood, but a meeting where everyone understood differently.)</p>
<p>A similar idea long used in BDD circles is the Three Amigos. The point is that the three perspectives of business, development, and testing each ask a different question, and the <a href="https://www.agilealliance.org/glossary/three-amigos/" target="_blank" rel="noopener noreferrer">Agile Alliance glossary</a> frames those questions as what problem are we trying to solve, how might we build a solution to solve it, and what about this, what could possibly happen. What becomes of a meeting where the third question never gets asked probably does not need spelling out.</p>
<h2 id="create-a-process-for-checking-what-you-do-not-know"><a class="anchor" href="#create-a-process-for-checking-what-you-do-not-know">Create a process for checking what you do not know</a></h2>
<p>But why do we have such a hard time playing the red card? At this point there is no avoiding a discussion of psychological safety.</p>
<p>The <a href="https://web.mit.edu/curhan/www/docs/Articles/15341_Readings/Group_Performance/Edmondson%20Psychological%20safety.pdf" target="_blank" rel="noopener noreferrer">paper</a> that Amy Edmondson published in Administrative Science Quarterly in 1999 is the study that formally established the concept of team psychological safety. (The paper itself credits Schein and Bennis's 1965 work as its root.) It defines team psychological safety as a shared belief that the team is safe for interpersonal risk taking. Saying you do not know, voicing dissent, and admitting a mistake all fall under that. The paper surveyed 51 teams at a manufacturing company and showed that psychological safety is associated with learning behavior, and that learning behavior mediates between psychological safety and team performance.</p>
<p>But the sentence I found most practical in that paper was not the definition; it was the one right after it.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>In most cases this belief remains tacit: it is taken for granted, and neither the individual members nor the team as a whole give it direct attention.</p></div><div class="quote-original" lang="en"><p>For the most part, this belief tends to be tacit—taken for granted and not given direct attention either by individuals or by the team as a whole.</p></div></blockquote>
<p>This belief is usually <strong>tacit</strong>, and neither individuals nor the team give it direct attention.</p>
<p>From here on this is my own conjecture. What the paper says stops at the belief being tacit; the prescription that you should therefore create a form is not in the paper. That said, I have seen several times that a tacit thing does not change through declaration. Saying “our team is a team where it is fine to ask about what you do not know” does not change much on its own. That is why I think the red card from the previous section matters. <strong>If you turn “I don't know” into a field inside a form, filling it in becomes procedure rather than courage.</strong></p>
<p>There is an obvious counterargument, of course. On an unsafe team, the red card field will simply stay empty. That is a fair point, and I have no intention of claiming this tool creates safety. But if the field exists, at least <strong>the fact that it is empty becomes visible.</strong> It creates an opening to ask whether it is empty because nobody has an open question, or empty because it is hard to speak up.</p>
<p><picture><source type="image/webp" srcset="/content/260723/3.720w.webp 720w, /content/260723/3.1440w.webp 1440w, /content/260723/3.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260723/3.jpg" alt="The DDD community&#x27;s Bounded Context Canvas template, with sections for purpose, strategic classification, ubiquitous language, assumptions, verification metrics, and open questions" width="1680" height="1100" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The same idea has made its way into actual document formats. Look at the <a href="https://github.com/ddd-crew/bounded-context-canvas" target="_blank" rel="noopener noreferrer">Bounded Context Canvas</a> created by the DDD community: it is a collaborative tool for designing and documenting a single context, and its arrangement of fields is interesting. Name and purpose, strategic classification, domain roles, inbound and outbound communication, and then <strong>Ubiquitous Language</strong>, business decisions, <strong>Assumptions</strong>, verification metrics, and <strong>Open Questions</strong>.</p>
<p>There is a separate field for writing the shared language, one for writing assumptions, and one for writing open questions. The canvas's own description says that the act of writing the purpose forces fuzzy thinking to be stated clearly and puts the whole team on the same page.</p>
<p>I think this is the most realistic approach to communication problems. Instead of trying to change people's attitudes, <strong>you make a field for the things that are hard to say.</strong> A good structure does not demand that people perform well; it makes performing well easy.</p>
<h2 id="why-this-is-not-a-matter-of-taste"><a class="anchor" href="#why-this-is-not-a-matter-of-taste">Why this is not a matter of taste</a></h2>
<p>Reading this far, one reaction is available: nice ideas, but isn't this ultimately just “communicate diligently”? And isn't that a personality trait?</p>
<p>I thought that for a while too. But there is data on this part.</p>
<p>When DORA addresses organizational culture, it borrows the sociologist Ron Westrum's typology: power-oriented pathological, rule-oriented bureaucratic, and performance-oriented generative. And <a href="https://dora.dev/capabilities/generative-organizational-culture/" target="_blank" rel="noopener noreferrer">DORA's official documentation</a> summarizes their research findings this way.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>An organizational culture built on high trust, one that places weight on the flow of information, predicts how well software delivery performs.</p></div><div class="quote-original" lang="en"><p>organizational culture that is high-trust and emphasizes information flow is predictive of software delivery performance</p></div></blockquote>
<p>An organizational culture that is high-trust and <strong>emphasizes information flow</strong> is predictive of software delivery performance. The claim is not that teams communicating well feel good, but that it is a variable moving together with performance.</p>
<p>That said, “predictive” must not be read as causal here. DORA's data comes from a survey in which the same respondents answered about both culture and performance, and it is a correlation at the organizational level. It remains possible that people satisfied with their organization rated both generously, and a prescription for how an individual should behave does not follow directly from a relationship at the organizational level. So this is as far as the material takes us: information flow does not live only in the realm of taste, it sits where it moves together with performance.</p>
<p>The same document also lays out Westrum's three properties of good information: it answers the question the receiver needs answered, it is timely, and it is presented in a way the receiver can use effectively. It is worth noticing that all three set the standard at the <strong>receiver</strong>. Sharing a lot is not what makes information flow good.</p>
<p>Google's Project Aristotle is another frequently cited source. According to <a href="https://rework.withgoogle.com/intl/en/guides/understand-team-effectiveness" target="_blank" rel="noopener noreferrer">what was published on reWork</a>, they studied 180 teams, ran more than 35 statistical models over hundreds of variables, and put forward five factors affecting team effectiveness: psychological safety, dependability, structure and clarity, meaning, and impact. And the conclusion was that how the team worked together mattered more than who was on it.</p>
<p>One thing is worth flagging, though. The official page states that it lists the five factors in order of importance and puts psychological safety first. But magnitude claims like “overwhelmingly number one,” which show up often in articles citing this research, are not on that page. Having an order and dominating the rest are different claims. So this post makes no claim about magnitude. If you want to argue for the importance of psychological safety, leaning on Edmondson's original paper from the previous section is the accurate move.</p>
<h2 id="back-to-the-leader-question"><a class="anchor" href="#back-to-the-leader-question">Back to the leader question</a></h2>
<p>Let us return to the article quoted at the start.</p>
<p>While covering information gathering first, James Samuel says the methods from your individual contributor days stop working. As an individual contributor you hold the whole picture of your own work, but once you become responsible for people you can no longer use the old approach. So he names as a required capability the ability to filter out noise and synthesize information into a coherent picture of reality, because no manager can process everything.</p>
<p>I felt this was the same point as Westrum's three properties of good information above. It is not about collecting a lot but about turning it into a usable form. And that is no different from what I am doing right now while handling requirements. Picking out the ambiguous parts of a spec's sentences, redrawing them as a breadboard to confirm, and leaving what I do not know as a red card is exactly the practice of filtering noise into a coherent picture.</p>
<p>What the author says about decision making stayed with me too: waiting for certainty is itself a decision, and it carries a cost. Waiting until requirements become completely clear is no different. Which is why the grounding criterion, sufficient for current purposes rather than perfectly understood, becomes the practical standard.</p>
<h2 id="closing-thoughts"><a class="anchor" href="#closing-thoughts">Closing thoughts</a></h2>
<p>Here is the summary.</p>
<p>Cross-functional communication failures are usually not purely a matter of attitude. When an organization contains different contexts, the same word diverging is the default state, and being aligned is the exception that has to be maintained at a cost. The way that cost gets paid is shared language, and within it the developer's share is not transcribing requirements but watching for ambiguity and handing it back.</p>
<p>But handing it back takes effort, and people try to minimize that effort. So instead of relying on willpower, I decided to use three things: checking first which mode this conversation is in, drawing the flow at the resolution between prose and mockup and handing it back, and building a field for what you do not know into the meeting format. None of the three demand that you fix your attitude; they make the right thing easier to do.</p>
<p>I am not a leader yet, and I have not verified any of this from that seat. Some of it will not hold at a different team size or in a different organizational culture. But there is one thing I feel confident about now. The way I handle information later will not be newly learned; it will be the way I handle requirements today, grown to scale. So these days I try to be less embarrassed than I used to be about asking “what do you mean by that?” in meetings.</p>
<p>If you can think of one word from a recent meeting that you nodded along to without actually being sure about, I would suggest using that word one more time next time. Use it repeatedly and it will surface.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://martinfowler.com/bliki/UbiquitousLanguage.html" target="_blank" rel="noopener noreferrer">Martin Fowler, Ubiquitous Language</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://www.domainlanguage.com/ddd/reference/" target="_blank" rel="noopener noreferrer">Eric Evans, Domain-Driven Design Reference</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://teamtopologies.com/key-concepts" target="_blank" rel="noopener noreferrer">Team Topologies, Key Concepts</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://basecamp.com/shapeup/1.2-chapter-03" target="_blank" rel="noopener noreferrer">Basecamp, Shape Up: Set Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.eventstorming.com/book/" target="_blank" rel="noopener noreferrer">Alberto Brandolini, EventStorming</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://domainstorytelling.org/" target="_blank" rel="noopener noreferrer">Stefan Hofer, Henning Schwentner, Domain Storytelling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://gojko.net/books/specification-by-example/" target="_blank" rel="noopener noreferrer">Gojko Adzic, Specification by Example</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>collaboration</category>
            <category>domain</category>
            <category>DDD</category>
            <category>communication</category>
        </item>
        <item>
            <title><![CDATA[Harness (Systems) Engineering]]></title>
            <link>https://hooninedev.com/en/260622</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260622</guid>
            <pubDate>Mon, 22 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to look at what may come after prompt engineering and context engineering. While wrapping up the previous article on saving tokens, one question kept nagging at me. I believe the ...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to look at what may come after prompt engineering and context engineering.</p>
<p>While wrapping up the previous article on <a href="/260611" target="_blank" rel="noopener noreferrer">saving tokens</a>, one question kept nagging at me. I believe the center of gravity is shifting away from crafting individual prompts and toward the practice of clearing away and selecting information—context engineering. Once that article was finished, the next question followed naturally: what comes after context?</p>
<p><img src="/content/260622/3.webp" alt="A 3-layer architecture for reliable AI systems, comprising prompt, context, and harness engineering" width="1680" height="920" loading="eager" fetchpriority="high" decoding="async"></p>
<p>Writing about “where things are headed” calls for caution. The future contains many possible paths, so the focus here is on <strong>a shift in emphasis visible in already-published primary sources</strong>. Some inference is inevitably involved, and I hope readers will approach it from multiple perspectives.</p>
<h2 id="from-prompts-to-context"><a class="anchor" href="#from-prompts-to-context">From prompts to context</a></h2>
<p>Let us begin by clarifying the terms. For a while, the industry’s central topic was <strong>prompt engineering</strong>. The problem was how to write a single instruction to a model well: how to design clear directions, useful examples, and an output format.</p>
<p>Then the unit of work grew. As agents running for dozens of turns became common, the important question was no longer one prompt but how to compose <strong>the entire context the model sees on every turn</strong> (system prompt + tool definitions + conversation history + search results + memory). This is called <strong>context engineering</strong>. Anthropic organized this frame in its September 2025 article “Effective context engineering for AI agents,” and the context rot research published that same year by the Chroma team (Hong et al.) added quantitative evidence. Across 18 models—including GPT-4.1, Claude 4, Gemini 2.5, and Qwen3—they showed that performance degrades unevenly as inputs grow longer, even on tasks as simple as copying words verbatim. In practice, the common assumption that a model treats the 100th token and the 10,000th token equally does not hold. The conclusion was not “longer context is better,” but “how information is placed matters as much as what it contains.” That finding reinforced the move from filling context to selecting it, and the term quickly took hold.</p>
<p>One common misunderstanding is worth addressing. Prompt engineering was not <strong>replaced</strong> by context engineering. Writing good prompts is still foundational; context engineering is closer to a higher-level concept built on top of it. (A shift in attention from writing good code to designing good systems does not make coding unnecessary.) The precise description, then, is not that we “switched over,” but that the field <strong>“expanded by inclusion.”</strong></p>
<p>So, to ask again: did that expansion stop at context? It does not appear so.</p>
<h2 id="the-anthropic-engineering-blog"><a class="anchor" href="#the-anthropic-engineering-blog">The Anthropic engineering blog</a></h2>
<p>I think the most honest way to gauge direction is to read, in chronological order, what organizations actually driving the field are writing about. Follow the Anthropic engineering blog after “Effective context engineering” (September 2025), and the titles alone begin to outline where the center of gravity moved next.</p>
<ul>
<li>October 2025, Equipping agents with Agent Skills</li>
<li>November 2025, Code execution with MCP: more efficient agents</li>
<li>November 2025, Effective harnesses for long-running agents</li>
<li>January 2026, Demystifying evals for AI agents</li>
<li>January 2026, Designing AI-resistant technical evaluations</li>
<li>February 2026, Quantifying infrastructure noise in agentic coding evaluations</li>
<li>March 2026, Harness design for long-running application development</li>
<li>April 2026, Scaling Managed Agents: separating brains from hands</li>
<li>May 2026, How to contain Claude across products</li>
</ul>
<p>Step back from the list and the keywords cluster into three branches: <strong>harness</strong>, <strong>eval</strong>, and <strong>containment</strong>. I read this as a discussion moving one level beyond how to fill and clear context, toward designing, measuring, and controlling the agent system as a whole. (There is, of course, a limitation here: these are one company’s priorities. Yet given that company’s role in the coding-agent ecosystem, it is difficult to dismiss them as the interests of only one vendor.)</p>
<p>Let us take them one at a time.</p>
<h2 id="harness"><a class="anchor" href="#harness">harness</a></h2>
<p>The word harness may be unfamiliar in this setting. Literally, it is the equipment fitted to a horse to direct its power where we want it to go. For an AI agent, a harness is <strong>the complete framework outside the model that surrounds it and puts it to work</strong>: which tools it may use and in what order, how it recovers after a failure, how far its permissions extend, and when its loop stops.</p>
<p>If context engineering asks, “What should we show the model?”, harness design asks, “How should the model move within this environment?” It sits one layer farther out. Imagine the model as a talented new hire: context is the work material handed to that person, while the harness is closer to the workplace and operating procedures. The same person’s performance can wobble in a chaotic environment, yet go much farther with the same abilities atop a well-designed process.</p>
<p>Why this matters becomes clear in <a href="https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents" target="_blank" rel="noopener noreferrer">a failure the Anthropic engineering team encountered firsthand</a>. They put their top coding model, Opus 4.5, on the Claude Agent SDK, supplied only high-level directions such as “build a clone of claude.ai,” and ran it across multiple sessions. Despite the model’s intelligence, it did not produce a production-grade app. The failures repeated in two forms. In one, a session tried to finish everything at once, exhausted its context mid-implementation, and left the next session a half-built feature. In the other, a later session saw that “quite a lot has already been done” and declared completion even though valid work remained. (Imagine a long job passed among people working shifts, with every replacement having no memory whatsoever of the person before them.)</p>
<p>The solution was not to make the model smarter, but to change the framework. The first session received a dedicated prompt for setting up the environment (an initializer agent), expanded more than 200 feature specifications into <code>feature_list.json</code>, and created <code>init.sh</code> to start the development server along with a progress log (<code>claude-progress.txt</code>). Each later session (coding agent) handled exactly one feature, left behind a clean state through a git commit and progress notes, and then ended. The next session first read that progress file and the git log to learn “how far the previous shift got” before continuing. The model was identical, but the result changed when placed in this framework. The harness, not the model, made the difference. (Interestingly, none of Anthropic’s remedies were new. Feature lists, small commits, progress notes, and smoke tests run every time are exactly what experienced developers do each day. A good framework for an agent is ultimately much like embedding good engineering habits into its environment.)</p>
<p>The connection to token efficiency is also clear. Subagent isolation, lean tool definitions, and model routing, which the previous article covered, look like separate cost-saving techniques in isolation. Taken together, however, they are parts of <strong>how one harness is designed</strong>. Harness design encompasses deciding which model lane receives each task, which tools remain enabled, and where verbose exploration is isolated. The savings are closer to a byproduct of that design.</p>
<p>Harness design has one peculiar trap, however: <strong>the better the framework, the more likely it is to become obsolete when the model improves.</strong> A harness is fundamentally a collection of assumptions about “what the model cannot do on its own.” The moment the model can do those things, those assumptions become excess baggage. An <a href="https://www.anthropic.com/engineering/managed-agents" target="_blank" rel="noopener noreferrer">example from Anthropic</a> illustrates this precisely. Sonnet 4.5 had a habit of rushing to finish as it approached its context limit—a behavior called context anxiety—so the framework included a context-reset mechanism. When the same framework was used with Opus 4.5, that behavior had disappeared, turning the carefully added reset into dead weight. Every time the model becomes one step smarter, another part of the framework can reach its expiration date.</p>
<p>That leads to a further idea: instead of perfecting a particular framework, <strong>design interfaces that remain stable even as the framework changes</strong>. Anthropic’s Managed Agents follows this direction, and its roots lie, perhaps unexpectedly, in operating systems. The OS endured for decades because it virtualized hardware into abstractions such as processes and files, creating a container in advance for programs that did not yet exist. A single line of <code>read()</code> works the same whether the storage is a 1970s disk or a modern SSD. Applying the same reasoning divides an agent into three pieces: the <strong>brain</strong> that makes decisions (Claude and the harness), the <strong>hands</strong> that perform actions (code-execution sandboxes and tools), and the <strong>session log</strong> that appends everything that occurs. With the three separated, the brain can handle a dead container as a tool-call error, and even if the harness dies, it can wake again from the last point in the session log. Cost and latency also fell as a side effect. By starting containers only when truly needed, Anthropic reports that time to first token (TTFT) fell by about 60% at the median and, at p95, by more than 90%. (This is another point of contact with the token-efficiency article. It recommended placing static material first for prompt caching; arranging context to improve that cache hit rate is exactly the job of the harness beside this brain.)</p>
<h2 id="eval"><a class="anchor" href="#eval">eval</a></h2>
<p>The second branch was the most interesting to me personally. As if by agreement, Anthropic’s articles from early 2026 converge on <strong>evaluation (eval)</strong>.</p>
<p>The reason is intuitive. <strong>How do we verify</strong> that context was composed well, the harness was designed well, or costs were truly reduced? The longer and more complex the tasks agents handle autonomously, the harder it becomes for a person to inspect each one and decide, “Did this actually work well?” The basis of trust ultimately shifts to measurement. That is why questions such as “How should agent evaluation be designed?”, “How do we remove noise from the evaluation itself?”, and “How do we handle eval awareness, where a model detects an evaluation and changes its behavior?” have moved to the foreground.</p>
<p><picture><source type="image/webp" srcset="/content/260622/4.720w.webp 720w, /content/260622/4.1440w.webp 1440w, /content/260622/4.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260622/4.png" alt="A chart contrasting pass@k and pass^k: as trials increase, pass@k climbs toward 100% while pass^k falls toward 0%" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Evaluating agents is difficult because it differs in kind from a one-shot question and answer. An agent calls tools and changes state over multiple turns, so one mistake propagates forward and accumulates. Results also vary from run to run even with identical inputs. <a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic</a> divides this nondeterminism into two metrics. <strong>pass@k</strong> is the probability of succeeding at least once across k attempts, so it rises as attempts increase. <strong>pass^k</strong> is the probability of succeeding on all k attempts, so it falls as attempts increase. pass@1 matters for code generation that only needs to be right once; pass^k is central for a customer-service agent that must work reliably every time. (If the per-trial success rate is 75%, the probability of three consecutive successes is 0.75³, or about 42%. The gap between “usually works” and “works every time” is that large.)</p>
<p>Then what grades a single attempt? The same article divides graders into three types. <strong>Code-based</strong> graders (test results, static analysis, and tool-call validation) are fast, inexpensive, and objective, but weak on open-ended tasks with multiple valid answers. <strong>Model-based</strong> graders (LLM-as-judge and rubric scoring) can capture subtle quality differences, but are nondeterministic and must be calibrated periodically against human judgments. <strong>Human-based</strong> grading is the most accurate but also slow and expensive. In practice, teams combine all three, ideally using deterministic grading as the foundation and model grading as support. There is another distinction. A <strong>capability eval</strong> asks, “What can this agent accomplish?” It therefore starts at a low score and provides a hill to climb. A <strong>regression eval</strong> asks, “Can it still do what it used to do?” It must remain near 100%; a lower score signals that something has broken.</p>
<p>One surprisingly common trap is that <strong>a low score may be the evaluation’s fault, not the agent’s.</strong> Anthropic reports that Opus 4.5 initially scored 42% on CORE-Bench. Investigation revealed causes such as rigid grading that expected “96.124991…” and marked “96.12” wrong, ambiguous task specifications, and irreproducible stochastic tasks. After fixing bugs and rerunning with a scaffold whose constraints had been loosened, the score jumped to 95%. They therefore emphasize one principle: <strong>do not take the score at face value; read the transcript yourself.</strong> If a frontier model makes 100 attempts and scores 0%, the problem is usually broken rather than the model being incapable.</p>
<p>It is also interesting that as measurement advances, the baseline itself moves quickly. On SWE-bench Verified—the leading coding-agent benchmark, which assigns real GitHub issues and grades whether tests pass—frontier models climbed from the 30% range to above 80% in 1 year. At that point, easy problems are all solved, scores reach a ceiling (saturation), and a paradox appears: large capability gains show up only as small score differences. One code-review startup reportedly dismissed a new model after looking only at one-shot evaluations, then recognized the improvement only after switching to agentic evaluations that measured longer, more complex work. An eval is therefore not something created once and finished; it becomes a living asset that must continually be replaced with harder versions. (Anthropic compares this to the “Swiss cheese model” in safety engineering. One slice full of holes cannot block a failure, but layering automated evaluation, production monitoring, and human transcript review means a failure that slips through one layer is caught by the next.)</p>
<h2 id="containment"><a class="anchor" href="#containment">containment</a></h2>
<p>The third branch has a somewhat different character. It is about <strong>safety and control</strong>, not cost or performance.</p>
<p>As agents gain more tools and move more autonomously, the blast radius of a single mistake grows with them. For an agent that can delete files, make external requests, and perform privileged actions on someone’s behalf, “Where do we stop the damage when it goes wrong?” matters as much as “How well does it perform?” The prominence of Anthropic’s May 2026 article on <a href="https://www.anthropic.com/engineering/how-we-contain-claude" target="_blank" rel="noopener noreferrer">containment across its products</a> can be read in this context. It divides agent risk into three branches: <strong>user misuse</strong>, in which a user maliciously or carelessly requests harmful work; <strong>model misbehavior</strong>, in which the model independently acts without being asked; and <strong>external attacks</strong> arriving through tools, files, or networks. One interesting observation is that smarter models do not simply reduce risk. Less capable models misread situations and make obvious mistakes; more capable models make fewer mistakes, but are better at discovering unexpected routes around constraints nobody explicitly wrote down.</p>
<p>Anthropic emphasizes the limits of “having a person supervise every action.” Claude Code initially sought safety by asking the user to approve each write, execution, and network-access request, but telemetry showed that users simply approved about 93% of the prompts. As approval windows multiply, <strong>approval fatigue</strong> sets in and people pay less attention to each one. A probabilistic defense that depends on human clicks will therefore retain holes. The center of gravity moves from “monitor what the agent is doing” to “restrict what the agent can do in the first place.” Defenses are layered across three levels: the <strong>environment layer</strong>, including sandboxes, VMs, and egress controls; the <strong>model layer</strong>, including system prompts and classifiers; and the <strong>external-content layer</strong>, including MCP, plugins, and search results. The central principle is to <strong>put the environment layer, which blocks actions deterministically, in place first</strong>. This is not because model-layer defenses are weak. On Gray Swan’s benchmark for prompt injection, the one-shot attack success rate is about 0.1%, which is best-in-class. But 100 adaptive attempts raise it to 5–6%, and a probabilistic defense inherently cannot achieve a 100% hit rate. A hard final boundary is therefore placed behind it. (Anthropic says introducing OS-level sandboxing reduced approval prompts by 84%. The safety mechanism actually reduced friction.)</p>
<p><picture><source type="image/webp" srcset="/content/260622/2.720w.webp 720w, /content/260622/2.1440w.webp 1440w, /content/260622/2.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260622/2.png" alt="3-layer containment defense: the model and external-content layers (probabilistic) are stacked above the environment layer (deterministic, the final line of defense)" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1198" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>This may seem remote from token efficiency, but the two share the same root. Both ask, <strong>“What should the agent be given, and how much?”</strong> Anthropic demonstrates the connection with two cases. In one, an internal red team phished an employee into running Claude Code with a malicious prompt. A quietly inserted instruction made it read <code>~/.aws/credentials</code> and POST the contents externally; it succeeded in 24 of 25 attempts. Because the user had typed the instruction directly, the model classifier saw nothing suspicious. What stopped it was not a smart model, but the environmental boundary that kept credentials outside the sandbox in the first place, together with egress control. The other case is subtler. An egress allowlist correctly allowed <code>api.anthropic.com</code>, but a file planted by an attacker used the attacker’s own API key to call Anthropic’s file-upload API, causing data to leave through the attacker’s account. The sandbox worked perfectly, yet the data still leaked. The lesson was that an allowlist must be understood not as a “destination filter,” but as “permission for every capability available through that domain.” (Anthropic repeatedly stresses this principle: proven hypervisors, syscall filters, and container runtimes held up; <strong>what actually broke were the components Anthropic had built on top of them</strong>.) Removing unused MCP tools belongs to the same context. It saves money while also reducing the attack surface. A design kept lean is not only cheaper and more accurate, but safer too.</p>
<h2 id="putting-it-all-together"><a class="anchor" href="#putting-it-all-together">Putting it all together</a></h2>
<p>The three branches can be condensed into one sentence. The unit of attention is expanding outward, one step at a time: <strong>from the prompt (one instruction), to context (what to show on each turn), and then to the whole agent system (how to operate it, measure it, and contain it)</strong>. Harness asks, “How should it run?”; eval asks, “How do we know it ran well?”; and containment asks, “How do we stop it when it runs wrong?”</p>
<p><picture><source type="image/webp" srcset="/content/260622/1.720w.webp 720w, /content/260622/1.1440w.webp 1440w, /content/260622/1.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260622/1.png" alt="A 3-stage expansion of attention from prompts to context and then to the whole agent system, branching into harness, eval, and containment" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1122" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>To stress the point once more, these three branches are <strong>a shift in emphasis that I infer from already published material</strong>, not a prediction that “this will become the standard in the second half of 2026.” Some currents will grow, while others will be absorbed under different names. What is clear is that neither prompts nor context will disappear; they will remain parts of a larger frame. The field has advanced by adding another layer on top rather than erasing old terms with new ones, and it seems likely to continue that way.</p>
<p>We started with cost and arrived here. The previous article recommended opening up your token ledger; this one goes a step farther. It is worth examining together <strong>how you verify</strong> the savings (eval), <strong>what framework makes those savings repeatable</strong> (harness), and <strong>how far that framework remains safe</strong> (containment). In the end, what lasts longest will probably not be any particular cost-saving technique, but the habit of measuring and controlling one’s own system.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic, Effective context engineering for AI agents</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/engineering/harness-design-long-running-apps" target="_blank" rel="noopener noreferrer">Anthropic, Harness design for long-running application development</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://research.trychroma.com/context-rot" target="_blank" rel="noopener noreferrer">Chroma Research, Context Rot</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Agents</category>
        </item>
        <item>
            <title><![CDATA[Kalyx]]></title>
            <link>https://hooninedev.com/en/260617</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260617</guid>
            <pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about Kalyx, the headless React DatePicker library I built. This is a rewrite of a retrospective I wrote in June 2026. The original post's headline claim, "seven pickers b...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>Kalyx</strong>, the headless React DatePicker library I built.</p>
<p>This is a rewrite of a retrospective I wrote in June 2026. The original post's headline claim, "seven pickers behind one API, smaller than a single calendar from a competing library," turned out on re-examination to be half wrong and half not a differentiator. So I am reorganizing it in three parts: why I built it, how it differs from existing options, and a technical definition.</p>
<p>To give the conclusion first, what sets Kalyx apart is not the number of components but its <strong>value model</strong>. The reference versions are <code>@kalyx/react</code> 1.4.7 and <code>@kalyx/core</code> 1.4.8 (MIT, React 19 only), and code excerpts come from the <a href="https://github.com/jiji-hoon96/kalyx" target="_blank" rel="noopener noreferrer">GitHub repository</a> at <code>main</code> as of 2026-09-16 (<code>0bb302e</code>).</p>
<h2 id="i-wanted-to-use-date-pickers-declaratively"><a class="anchor" href="#i-wanted-to-use-date-pickers-declaratively">I wanted to use date pickers declaratively</a></h2>
<p>I had two reasons for building it. I wanted to use complex, hard-to-use date libraries in a more declarative and simple way, and I wanted to learn how such a library is built on the inside. "Hard to use" is vague, so I went back to each library's type definitions to check whether the places I got stuck are still there in the latest versions.</p>
<h3 id="an-api-that-switches-modes-with-props"><a class="anchor" href="#an-api-that-switches-modes-with-props">An API that switches modes with props</a></h3>
<p>react-datepicker turns on time selection with <code>showTimeSelect</code>, month selection with <code>showMonthYearPicker</code>, year selection with <code>showYearPicker</code>, and range selection with <code>selectsRange</code>. One component becomes a different thing depending on its prop combination. The cost shows up in the types. In the 9.1.0 type definitions, depending on the value of <code>selectsRange</code>, the <code>onChange</code> signature forks.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// react-datepicker/dist/index.d.ts (발췌)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selectsRange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selectsMultiple</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    formatMultipleDates</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> never;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">event</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">MouseEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">HTMLElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">KeyboardEvent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">HTMLElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>It is a well-crafted union, but branches multiply as modes are added, and the consumer has to reconstruct which combination is active just from prop names. (This has the same shape as "an abstraction that bundles the wrong things increases coupling," which I covered in my post on <a href="/260201" target="_blank" rel="noopener noreferrer">abstraction</a>.) I wanted "an input, a popover, and a calendar" to be readable from the JSX structure itself.</p>
<h3 id="where-value-types-and-timezones-leak"><a class="anchor" href="#where-value-types-and-timezones-leak">Where value types and timezones leak</a></h3>
<p>react-datepicker and react-day-picker pass native <code>Date</code> objects around. Both do have an IANA <code>timeZone</code> prop. react-datepicker requires the optional peer <code>date-fns-tz</code> and react-day-picker's is experimental, but either way the value type is still <code>Date</code>. Because a <code>Date</code> is interpreted in the runtime's local timezone, in Seoul, <code>new Date(2026, 3, 15)</code> run through <code>toISOString()</code> gives <code>2026-04-14T15:00:00.000Z</code>. You pick April 15, and the server sees the 14th. react-datepicker's <a href="https://github.com/Hacker0x01/react-datepicker/issues/1018" target="_blank" rel="noopener noreferrer">"Date Selected is One Day Off"</a> issue was opened in September 2017 and closed in December 2025.</p>
<p>On the other side, Ark UI and React Aria use <code>@internationalized/date</code> objects such as <code>CalendarDate</code> and <code>ZonedDateTime</code>. The semantics are precise, but in an app where form state and server responses are all strings, conversion code appears at every boundary.</p>
<p>Timezone support can also be tied to your choice of date library. Looking at the adapter code in MUI X Date Pickers 9.13.0, the dayjs, Luxon, and Moment adapters have <code>isTimezoneCompatible = true</code>, while the date-fns family has <code>false</code>. An app on date-fns has to bring in a second date library to use the <code>timezone</code> prop.</p>
<p>Modes were scattered across prop combinations, values across <code>Date</code> objects whose interpretation depends on the runtime, and timezone support across the choice of date library. <strong>It was hard to express intent in a single declaration.</strong></p>
<h3 id="learning-by-building"><a class="anchor" href="#learning-by-building">Learning by building</a></h3>
<p>A date picker looks small, but it contains calendar arithmetic, locales, timezones and DST, keyboard navigation, and SSR. So I set two goals: on the consumer side, you should be able to read what is being built just from the JSX; on the implementation side, the code should be narrow enough that I can explain where values get converted.</p>
<p>So was there really no library that had already solved these needs?</p>
<h2 id="how-it-differs-from-existing-options"><a class="anchor" href="#how-it-differs-from-existing-options">How it differs from existing options</a></h2>
<p>The short answer is that there was. I started out believing that no library was both headless and shipped multiple pickers, but when I researched again, that premise was wrong.</p>
<h3 id="the-value-model-each-option-chose"><a class="anchor" href="#the-value-model-each-option-chose">The value model each option chose</a></h3>
<p>The following was verified on 2026-09-16 by installing the latest version of each package from npm and checking its type definitions and official documentation. The size column was measured with the same method described below.</p>
<table>
<thead>
<tr>
<th>Library</th>
<th>Headless</th>
<th>Value type</th>
<th>Time input</th>
<th>Month/year-only selection</th>
<th>gzip</th>
</tr>
</thead>
<tbody>
<tr>
<td>react-day-picker 10.0.1</td>
<td>No (ships CSS)</td>
<td><code>Date</code></td>
<td>None</td>
<td>None</td>
<td>20.0KB</td>
</tr>
<tr>
<td>react-datepicker 9.1.0</td>
<td>No (CSS import)</td>
<td><code>Date</code></td>
<td><code>showTimeSelect</code></td>
<td>Via props</td>
<td>45.4KB</td>
</tr>
<tr>
<td>MUI X 9.13.0</td>
<td>No (Material)</td>
<td>Adapter object</td>
<td>TimePicker</td>
<td><code>views</code></td>
<td>113.1KB</td>
</tr>
<tr>
<td>Ark UI 5.39.2</td>
<td>Yes</td>
<td><code>@internationalized/date</code></td>
<td>Separate <code>DateInput</code> segments (not in size)</td>
<td><code>minView</code></td>
<td>42.7KB</td>
</tr>
<tr>
<td>React Aria Components 1.21.1</td>
<td>Yes</td>
<td><code>@internationalized/date</code></td>
<td><code>TimeField</code> segments</td>
<td>None</td>
<td>75.3KB (DatePicker), 78.8KB (with range and time)</td>
</tr>
<tr>
<td>Kalyx 1.4.7</td>
<td>Yes</td>
<td>ISO 8601 UTC string</td>
<td>List-based HourList, MinuteList</td>
<td>MonthPicker, YearPicker</td>
<td>18.9KB (DatePicker), 25.6KB (all)</td>
</tr>
</tbody>
</table>
<p>react-day-picker states in its <a href="https://daypicker.dev/guides/timepicker" target="_blank" rel="noopener noreferrer">official guide</a> that "DayPicker does not include a built-in time picker." The MUI size includes <code>@mui/material</code> and emotion, so if you already have a MUI app, the increase is much smaller.</p>
<h3 id="complete-headless-options-already-exist"><a class="anchor" href="#complete-headless-options-already-exist">Complete headless options already exist</a></h3>
<p>The rows that matter in the table are Ark UI and React Aria. <a href="https://ark-ui.com/docs/components/date-picker" target="_blank" rel="noopener noreferrer">Ark UI</a> offers single, multiple, and range selection plus month- and year-level selection through a dot notation composition API such as <code>DatePicker.Root</code>. <a href="https://react-aria.adobe.com/DatePicker" target="_blank" rel="noopener noreferrer">React Aria</a> is Adobe's accessibility-focused implementation. So Kalyx's niche is narrower than I first thought.</p>
<blockquote>
<p>Complete headless options already exist. However, Ark UI and React Aria exchange values as <code>@internationalized/date</code> objects and offer time input as segmented fields. Kalyx fixes values as UTC instant strings that go straight into JSON, and puts a list-based TimePicker along with month, year, and week pickers into the same composition API.</p>
</blockquote>
<p>Still, "values are strings, which is nice" is a weak claim. A <code>CalendarDate</code> becomes a string with a single <code>toString()</code>. The differentiator is not the format but <strong>the contract that the string is always a real moment (an instant) and never mixed up with a calendar cell (a coordinate), and the tests that keep that contract</strong>.</p>
<h3 id="re-measuring-bundle-size-with-one-method"><a class="anchor" href="#re-measuring-bundle-size-with-one-method">Re-measuring bundle size with one method</a></h3>
<p>The bundle size on the README badge was not a quantity you could compare with other libraries. The badge's roughly 19.5KB is a single file in <code>@kalyx/react</code>'s <code>dist</code>, and that file leaves <code>@kalyx/core</code>, <code>@kalyx/adapter-date-fns</code>, and <code>@floating-ui/react</code> as external imports. I had placed that number next to other libraries' numbers that included their dependencies.</p>
<p>So I re-measured everything as "how much does the bundle grow when a consumer app adds one import line?"</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">echo</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "import { DatePicker } from '@kalyx/react'; export default DatePicker;"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> entry.jsx</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> esbuild</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> entry.jsx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --bundle</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --minify</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --format=esm</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --platform=browser</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> \</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  --external:react</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --external:react-dom</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --external:react/jsx-runtime</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> gzip</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -6</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> wc</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -c</span></span></code></pre></figure>
<p>Measured on 2026-09-16 with esbuild 0.28.2, and KB here means bytes divided by 1024. For react-datepicker I excluded the CSS. For React Aria Components I measured twice: with the 12 exports needed to assemble a single DatePicker (<code>DatePicker</code>, <code>DateInput</code>, <code>Calendar</code>, <code>Popover</code>, <code>Dialog</code>, and so on), and with 14 exports that add range and time (including <code>DateRangePicker</code>, <code>RangeCalendar</code>, and <code>TimeField</code>).</p>
<p><picture><source type="image/webp" srcset="/content/260617/1.720w.webp 720w, /content/260617/1.1440w.webp 1440w, /content/260617/1.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260617/1.png" alt="Measured under the same esbuild conditions, sizes grow in this order: Kalyx DatePicker 18.9KB, react-day-picker 20.0KB, all of Kalyx 25.6KB, Ark UI 42.7KB, react-datepicker 45.4KB, React Aria Components 75.3KB (78.8KB with range and time), MUI X 113.1KB." style="max-width:min(720px, 100%);height:auto;" width="1680" height="980" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>Under the same conditions, one statement holds. One DatePicker (18.9KB) is about the same size as <code>DayPicker</code> (20.0KB), and all seven together (25.6KB) are larger. (Even then, DayPicker is just a calendar, while Kalyx DatePicker also includes the input and popover.) Size is sensitive to the import combination and gzip level, so it is better read as an order of magnitude, and size is not the main reason to choose Kalyx.</p>
<h2 id="defining-kalyx-technically"><a class="anchor" href="#defining-kalyx-technically">Defining Kalyx technically</a></h2>
<blockquote>
<p>Kalyx is a headless React date picker that fixes all input and output as UTC instant strings, confines conversion between calendar coordinates and instants to two functions, and checks that round trip with property-based tests across every timezone the runtime knows.</p>
</blockquote>
<p>Here is the consumer-facing API. <code>value</code> and <code>onChange</code> use <code>string | null</code>, and <code>displayTimezone</code> decides which zone's calendar is shown.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{iso} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setIso} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">displayTimezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"America/New_York"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Input</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Popover</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Calendar</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker.Popover</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePicker</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<h3 id="values-are-instants-calendar-cells-are-coordinates"><a class="anchor" href="#values-are-instants-calendar-cells-are-coordinates">Values are instants, calendar cells are coordinates</a></h3>
<p>Two kinds of ISO strings with the same format flow through Kalyx. A <strong>coordinate</strong> is a single cell of the calendar grid, written as <code>YYYY-MM-DDT00:00:00.000Z</code> but carrying no notion of timezone. Grid computation runs only in UTC, so it is independent of the runtime environment. An <strong>instant</strong> is the value that leaves through <code>onChange</code>: the actual moment that is midnight of that day in <code>displayTimezone</code>. The same January 15 is <code>2026-01-15T05:00:00.000Z</code> in New York and <code>2026-01-14T15:00:00.000Z</code> in Seoul.</p>
<p><picture><source type="image/webp" srcset="/content/260617/2.720w.webp 720w, /content/260617/2.1440w.webp 1440w, /content/260617/2.1640w.webp 1640w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260617/2.png" alt="The calendar coordinate 2026-01-15T00:00:00.000Z becomes the New York and Seoul instants through civilMidnightFromUtcDay, and returns to the coordinate through calendarDayFromInstant." style="max-width:min(720px, 100%);height:auto;" width="1640" height="1040" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Only two functions in <code>@kalyx/core</code> connect them: <code>civilMidnightFromUtcDay</code>, which turns a coordinate into an instant, and its inverse, <code>calendarDayFromInstant</code>. The latter reads "what day is this instant in this zone?" and writes it back as a UTC-midnight coordinate.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/utils/timezone.ts:187-190</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calendarDayFromInstant</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ISODateString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timeZone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ISODateString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> p</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> partsInTimezone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(iso), timeZone);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">UTC</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(p.year, p.month </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, p.day)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The rule is about direction. When a cell is picked and the value is committed, a coordinate becomes an instant; when the stored value determines which month to show and where focus goes, an instant becomes a coordinate. The DatePicker Root's <code>selectDate</code> converts, checks constraints against the converted instant, and commits if it passes.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/react/src/components/DatePicker/Root.tsx:182-194</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> normalized</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  coordinate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> displayTimezone</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ?</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> civilMidnightFromUtcDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate, displayTimezone)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> coordinate;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (normalized </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isDateDisabled</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized, disabledRules, adapter, displayTimezone)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">isControlled) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setUncontrolledValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(normalized);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">?.(normalized);</span></span></code></pre></figure>
<p>This is not the only call site. Searching <code>packages/react/src</code> shows the two functions scattered across the Root and Calendar of DatePicker, RangePicker, and DateTimePicker, the Presets, the keyboard navigation utilities, and six headless hooks. Even so, every path that commits a date cell or decides the view goes through one of the two. Committing a time is handled by <code>setTimeInTimezone</code>, which converts internally through the same internal function, <code>resolveCivilDateTime</code>.</p>
<p>This contract is guarded by property-based tests. For every coordinate <code>c</code> and zone <code>z</code>, <code>calendarDayFromInstant(civilMidnightFromUtcDay(c, z), z) === c</code> must hold.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/__tests__/timezone.property.test.ts:86-94</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">it</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'round-trips every UTC calendar coordinate through civil midnight'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  fc.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assert</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    fc.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">property</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">utcCalendarCoordinate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">zone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">coordinate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timeZone</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> instant</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> civilMidnightFromUtcDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate, timeZone);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      expect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">calendarDayFromInstant</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(instant, timeZone)).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toBe</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(coordinate);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    RUNS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><a href="https://fast-check.dev/" target="_blank" rel="noopener noreferrer">fast-check</a> randomly generates dates between 2020 and 2045 and combines them with 14 representative zones, including +5:45 Kathmandu, +14 Kiritimati, and -11 Niue, for 300 runs. The very next test checks the same round trip 12 times per zone for every zone returned by <code>Intl.supportedValuesOf('timeZone')</code>. On my local Node 24.16, that list has 418 entries. 1.4.8 added an exhaustive test that compares conversions at the boundary times of every DST transition against a Temporal implementation.</p>
<p>Then why not have the library normalize a user-supplied <code>"2026-01-15T00:00:00.000Z"</code> into an instant on its own? That road is closed because you cannot tell from the string alone whether it is a coordinate or an instant. Take the Seoul value <code>2026-01-14T15:00:00.000Z</code>, which is already an instant, apply <code>civilMidnightFromUtcDay</code> to it again, and you get <code>2026-01-13T15:00:00.000Z</code>; in positive-offset zones like Seoul, the date keeps slipping by a day on every render. (The New York value stays the same when applied again.) Switching to <code>startOfDayInTimezone</code>, which gives the same result no matter how many times it is applied, removes the slipping but can no longer do the original job of turning a coordinate into an instant.</p>
<p>So I gave up on normalization and fixed the contract in documentation. Consumers must pass back exactly the value the picker emitted. Honestly, that is a cost pushed onto consumers, and because <code>ISODateString</code> is an alias of <code>string</code>, the compiler cannot stop mistakes either.</p>
<h3 id="solving-dst-with-intl-alone"><a class="anchor" href="#solving-dst-with-intl-alone">Solving DST with Intl alone</a></h3>
<p>To turn a coordinate into an instant, you need to know when "00:00 on that day in that zone" is in UTC, but you can only get the offset once you know the instant. On top of that, on DST transition days a local time may not exist (spring forward) or may occur twice (fall back).</p>
<p>Kalyx solves this without a library like <code>date-fns-tz</code>. It asks <code>Intl.DateTimeFormat(...).formatToParts</code> "what local time is this UTC moment in that zone?" to measure the offset, and reads that offset at two points: one day before and one day after the requested time.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/utils/timezone.ts:251-254, 259</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">probeEpoch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getTimezoneOffsetMinutes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(probeEpoch).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), timeZone) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> epochBefore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> epochAfter</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> candidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(civilEpoch </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (epochBefore </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> epochAfter) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(epochBefore).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toISOString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p><code>civilEpoch</code> is the desired local time read as if it were UTC. Offsets stay within ±14 hours, so one day before and one day after give the offsets on either side of any nearby transition. If the two agree there is no transition, and the work ends with two <code>formatToParts</code> calls; since the grid calls this once for each of its 42 cells, this fast path determines the cost. (It assumes at most one transition within 48 hours, which holds in every zone from 2020 to 2045.)</p>
<p>If they differ, it re-reads both candidates in that zone and checks which matches the requested time (<code>timezone.ts:261-279</code>). In a fall back overlap both match, so it picks the earlier one built from the pre-transition offset; in a spring forward gap neither matches, so it picks the same side and moves the time forward by the length of the gap. Here is what it actually returns.</p>
<table>
<thead>
<tr>
<th>Request</th>
<th>Situation</th>
<th>1.4.7</th>
<th>1.4.8</th>
</tr>
</thead>
<tbody>
<tr>
<td>New_York 2026-03-08 02:30</td>
<td>Nonexistent time</td>
<td><code>2026-03-08T07:30:00.000Z</code></td>
<td>Same (03:30 EDT, moved forward)</td>
</tr>
<tr>
<td>New_York 2026-11-01 01:30</td>
<td>Time that occurs twice</td>
<td><code>2026-11-01T05:30:00.000Z</code></td>
<td>Same (01:30 EDT, earlier one)</td>
</tr>
<tr>
<td>London 2026-10-25 01:30</td>
<td>Time that occurs twice</td>
<td><code>2026-10-25T01:30:00.000Z</code> (later one)</td>
<td><code>2026-10-25T00:30:00.000Z</code> (01:30 BST, earlier one)</td>
</tr>
</tbody>
</table>
<p>"Gaps move forward, ambiguity takes the earlier one" matches the <code>disambiguation</code> default <code>"compatible"</code> described in <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/ZonedDateTime" target="_blank" rel="noopener noreferrer">MDN's Temporal.ZonedDateTime documentation</a>. Why the 1.4.7 London row is off is covered later.</p>
<h3 id="the-adapter-boundary-is-21-string-methods"><a class="anchor" href="#the-adapter-boundary-is-21-string-methods">The adapter boundary is 21 string methods</a></h3>
<p>If core computes timezones, what do date-fns or dayjs handle? Date arithmetic and parsing. That boundary, <code>DateAdapter</code>, has 21 methods, and every date argument and return value is an ISO string.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/core/src/types.ts:71-102 (발췌)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> DateAdapter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  parse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">format</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  format</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">formatStr</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  addDays</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">n</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  isSameDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  startOfDay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">iso</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  today</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">timezone</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // addMonths, isBefore, startOfMonth, getYear 등 15개</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Four methods accept a timezone, <code>format</code>, <code>isSameDay</code>, <code>startOfDay</code>, and <code>today</code>, and even those four hand the computation to core. The date-fns adapter's <code>format</code>, when given a <code>timezone</code>, calls core's <code>formatInTimezone</code> directly (<code>packages/adapter-date-fns/src/index.ts:112-115</code>). So whichever adapter you use, timezone answers come from the same code, and the three adapters (date-fns, dayjs, luxon) each run <code>@kalyx/core/test-helpers</code>'s <code>runAdapterConformanceTests</code> to confirm they produce the same answers.</p>
<p>The two entry points also split along this boundary. The default entry, <code>@kalyx/react</code>, calls <code>setDefaultAdapter(DateFnsAdapter)</code> at module load, so it works right after installation (<code>packages/react/src/index.ts:9-11</code>). <code>@kalyx/react/headless</code> has no such call and is bundled separately, so no date-fns code gets in.</p>
<p>This boundary shape came at a price. In June 2026, I shelved a Temporal adapter. Temporal's value lies in <strong>types carrying meaning</strong>, as with <code>PlainDate</code> and <code>ZonedDateTime</code>, but when the boundary is a string, that meaning cannot pass through. Wrapping it would only flatten it into a string, so I judged there was no correctness gain. Looking back, the problem from the previous section, "coordinates and instants are the same <code>string</code>," is exactly what Temporal solves in the type system with <code>PlainDate</code> and <code>Instant</code>. The string boundary made swapping adapters easy, but in exchange it closed off the path to solving this with types.</p>
<h3 id="seven-pickers-are-combinations-of-three-contexts"><a class="anchor" href="#seven-pickers-are-combinations-of-three-contexts">Seven pickers are combinations of three contexts</a></h3>
<p>Kalyx ships seven pickers: DatePicker, RangePicker, TimePicker, DateTimePicker, MonthPicker, YearPicker, and WeekPicker. What matters is not the count but <strong>the fact that the seven are not independent implementations</strong>. There are only three contexts: <code>DatePickerContext</code>, <code>RangePickerContext</code>, and <code>TimePickerContext</code>.</p>
<table>
<thead>
<tr>
<th>Picker</th>
<th>Root that holds state</th>
<th>Context</th>
<th>Where the difference comes from</th>
</tr>
</thead>
<tbody>
<tr>
<td>DatePicker</td>
<td><code>DatePickerRoot</code></td>
<td>Date</td>
<td></td>
</tr>
<tr>
<td>MonthPicker, YearPicker</td>
<td>DatePicker's Root implementation</td>
<td>Date</td>
<td><code>selectionGranularity</code></td>
</tr>
<tr>
<td>RangePicker</td>
<td><code>RangePickerRoot</code></td>
<td>Range</td>
<td></td>
</tr>
<tr>
<td>WeekPicker</td>
<td><code>RangePickerRoot</code> as is</td>
<td>Range</td>
<td><code>selectionMode="week"</code> on Calendar</td>
</tr>
<tr>
<td>TimePicker</td>
<td><code>TimePickerRoot</code></td>
<td>Time</td>
<td></td>
</tr>
<tr>
<td>DateTimePicker</td>
<td>Its own Root</td>
<td>Date and Time nested</td>
<td>Stacks two Providers</td>
</tr>
</tbody>
</table>
<p>MonthPicker's Root merely changes the default display format to <code>yyyy-MM</code> and passes <code>selectionGranularity="month"</code> to DatePicker's Root implementation (<code>MonthPicker/Root.tsx:11-20</code>). DatePicker's <code>selectDate</code>, when the granularity is <code>month</code>, folds the coordinate to the 1st of that month and then goes through the same conversion and constraint check shown earlier. DateTimePicker provides the two contexts stacked.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// packages/react/src/components/DateTimePicker/Root.tsx:401-402</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DatePickerContext.Provider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{dateContext}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TimePickerContext.Provider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{timeContext}>{children}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TimePickerContext.Provider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>So <code>DateTimePicker.Calendar</code> is the same component as <code>DatePicker.Calendar</code>, and it does not know which picker it is inside. A fix in one place reaches every picker that uses the same component, and so does a defect in one place. This shared foundation is also why a single DatePicker came to 74% of the whole in the earlier chart.</p>
<h2 id="what-keeping-the-contract-cost"><a class="anchor" href="#what-keeping-the-contract-cost">What keeping the contract cost</a></h2>
<p>I spent the three months since 1.0 verifying this contract more than building new features, and it surfaced three defects that example-based tests would have let through. The first was found by a property test, the second by a cross-review of the code, and the third by fact-checking this post.</p>
<h3 id="one-hour-on-october-1-in-sydney"><a class="anchor" href="#one-hour-on-october-1-in-sydney">One hour on October 1 in Sydney</a></h3>
<p>The first was that the property "reading the result of <code>startOfDayInTimezone</code> in that zone gives 00:00:00" broke in Australia/Sydney. The implementation at the time measured the offset only once. Sydney moves from +10 to +11 at 02:00 on October 1, 2034, and 00:00 that day is still +10, so the correct answer is <code>2034-09-30T14:00:00.000Z</code>. But "the point where 00:00 on October 1 is read as UTC" is after the transition, so it returned +11, and the result was 23:00 on the previous day. This counterexample remains as a regression test (<code>timezone.property.test.ts:276</code>).</p>
<h3 id="a-test-that-passed-only-in-seoul"><a class="anchor" href="#a-test-that-passed-only-in-seoul">A test that passed only in Seoul</a></h3>
<p>The second came out of a cross-review on August 3, 2026. The code that decides which calendar cell to mark as selected was converting twice. The cell was already a coordinate, yet both the cell and the stored value were converted to that zone's date. Here is what picking "January 15" produced.</p>
<table>
<thead>
<tr>
<th>Zone</th>
<th>Stored value</th>
<th>Cell marked as selected</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Asia/Seoul</code> (+9)</td>
<td><code>2026-01-14T15:00:00.000Z</code></td>
<td>15th (correct)</td>
</tr>
<tr>
<td><code>America/New_York</code> (-5)</td>
<td><code>2026-01-15T05:00:00.000Z</code></td>
<td>16th (wrong)</td>
</tr>
</tbody>
</table>
<p>In positive-offset zones the two shifts cancelled out and it happened to be right, and the existing tests covered only Seoul. The same review also found a violation in the opposite direction. When deciding which month to show, the code applied <code>startOfMonth</code> directly to an instant, so passing January 1 as the value in Seoul opened the December calendar.</p>
<p>The mechanisms differ, but both broke the same rule: convert only once per direction, with the function assigned to that direction.</p>
<p>This incident widened core's round-trip property tests from "a few representative zones" to "every zone the runtime knows." (Component tests on the React side still use representative zones such as <code>America/New_York</code>.) <strong>Defects that only appear where the sign flips are not caught by sampling.</strong></p>
<h3 id="londons-0130-resolved-late"><a class="anchor" href="#londons-0130-resolved-late">London's 01:30 resolved late</a></h3>
<p>The third came up while I extended this post's DST table to other zones. 1.4.7 started measuring the offset at the point where the requested time is read as UTC, and in zones whose post-transition offset is 0 or more, that point is already past the transition, so it converged on the later offset. Checking every transition from 2020 to 2045 in 418 zones against temporal-polyfill, 1,908 of the 3,395 overlap transitions resolved to the later instant, across 84 zones. (All 3,394 gap transitions were correct, and New York, with its negative offset, just happened to land on the right side.)</p>
<p>In <a href="https://github.com/jiji-hoon96/kalyx/pull/226" target="_blank" rel="noopener noreferrer">#226</a> I changed it to read one day before and after, released it as <code>@kalyx/core</code> 1.4.8, and kept the same comparison as <code>timezone.dst-oracle.test.ts</code>. (temporal-polyfill is used only in tests.) The source comment that wrongly said <code>'earlier'</code> now says <code>'compatible'</code>. Once again, <strong>the samples were concentrated on one sign.</strong></p>
<h3 id="the-3kb-spent-on-correctness"><a class="anchor" href="#the-3kb-spent-on-correctness">The 3KB spent on correctness</a></h3>
<p>These fixes cost code. Kalyx sets a CI ceiling on the default entry bundle, and a PR that exceeds it fails a required check. That ceiling started at 12KB and went up 1KB each time a feature landed, and in August 2026, while overhauling timezone and constraint correctness, I raised it from 17KB to 20KB in one step.</p>
<p>Size was a selling point shown on the README badge. A date picker that slips by a day in negative-offset zones is unusable whether it is small or large.</p>
<p><strong>If I have to choose between small and correct, I choose correct.</strong> The ceiling is tight now. According to the repository's bundle byte map document from 2026-09-11, <code>dist/index.cjs</code> measured with Node's default gzip is 20,259B against a ceiling of 20,480B, leaving 221B of headroom. (This is the size of its own file with dependencies left external, so it is a different quantity from the earlier chart.) The next feature will have to reclaim bytes before it can land.</p>
<h2 id="not-the-conclusion-i-started-with"><a class="anchor" href="#not-the-conclusion-i-started-with">Not the conclusion I started with</a></h2>
<p>What I wanted was to use complex date libraries declaratively. After building Kalyx, I found that declarative composition APIs and complete headless options already existed. The remaining difference lay deeper. <strong>Fix the value as a single instant, narrow conversion between coordinates and instants to two functions, and guard that round trip with tests in every timezone.</strong> The Intl-based DST handling, the string adapter boundary, and the seven pickers built from three contexts are the consequences of that decision.</p>
<p>Measured against my goal of learning, the biggest lesson was how to claim that something is "correct." The original post's bundle comparison was measuring different quantities, and the test that passed in Seoul was wrong in New York. Unless you also record what you measured and which samples you checked against, numbers easily turn into bragging.</p>
<p>The limits are clear too. I am the only maintainer, it supports React 19 only, and npm downloads of <code>@kalyx/react</code> from September 5 to 11, 2026 were 200, of which 156 were concentrated on the single day 1.4.7 was released. The inability to separate coordinates and instants in the type system is guarded only by documentation, and I have not decided whether to guarantee the styling hooks, <code>classNames</code> and <code>data-*</code> attributes, as public API. For accessibility, it has the <code>grid</code> role on calendars, <code>combobox</code> on inputs, and <code>listbox</code> on time lists, with arrow key, Home/End, and PageUp/PageDown navigation, and CI runs the <code>jest-axe</code> checks in 8 test files, but I have no basis yet to say it is as proven as React Aria.</p>
<p>So if you already have a MUI app, look at MUI X first; if segmented input and proven accessibility come first, React Aria; if you only need a calendar, react-day-picker. If you have run into dates slipping by a day in forms where values travel as JSON, that is when I would be glad if you took a look at Kalyx, and if you know a better solution, I would be grateful if you let me know through a GitHub Issue.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">pnpm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> add</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @kalyx/react</span></span></code></pre></figure>
<p>On the docs site's <a href="https://kalyx-docs-site.vercel.app/playground" target="_blank" rel="noopener noreferrer">Playground</a>, you can try the seven pickers and change locale and timezone settings yourself.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://kalyx-docs-site.vercel.app/" target="_blank" rel="noopener noreferrer">Kalyx official documentation site</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://mui.com/x/react-date-pickers/timezone/" target="_blank" rel="noopener noreferrer">MUI, Date and Time Pickers Timezone</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://floating-ui.com/" target="_blank" rel="noopener noreferrer">Floating UI official documentation</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>Library</category>
            <category>React</category>
            <category>DatePicker</category>
            <category>Open-Source</category>
        </item>
        <item>
            <title><![CDATA[How to Save AI Tokens]]></title>
            <link>https://hooninedev.com/en/260611</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260611</guid>
            <pubDate>Thu, 11 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about how to save AI tokens. In the early days, I focused on results and process rather than performance and cost. AI-generated output had plenty of gaps, so it needed ver...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about how to save AI tokens.</p>
<p>In the early days, I focused on results and process rather than performance and cost. AI-generated output had plenty of gaps, so it needed verification, and the pressure to produce results quickly led many people, myself included, to pay for more tokens or move to a higher subscription tier whenever they ran short. I did the same. (For the first few months, I did not even pay attention to how much tokens were costing me.)</p>
<p>After a while, however, I became increasingly conscious of token usage. Individuals felt the burden of monthly subscriptions, while companies grew more concerned about labor and operating costs. <a href="/260524" target="_blank" rel="noopener noreferrer">MCP and Function Calling</a> covered the protocol that connects agents to external systems, <a href="/260526" target="_blank" rel="noopener noreferrer">Four Tiers of Code Intelligence</a> covered code search tools and GitHub Trending, and <a href="/260529" target="_blank" rel="noopener noreferrer">Context Files</a> covered the rule files that agents read. In this way, my articles so far have focused more on which tools exist and how to use them well. I still think those topics matter, but as time passes, cost will ultimately become the question people care about most.</p>
<p>I covered the mechanics of tokens—what exactly they are, how BPE creates them, and what happens inside a transformer when prompt caching lowers the unit price—in a separate article, <a href="/260610" target="_blank" rel="noopener noreferrer">How Tokens Work</a>. Building on that foundation, this article first examines how costs are billed and where inefficiencies arise, then organizes proven cost-saving patterns. For the main patterns, I attached token usage I measured myself by running the same task under different strategies.</p>
<h2 id="where-do-token-costs-come-from"><a class="anchor" href="#where-do-token-costs-come-from">Where Do Token Costs Come From?</a></h2>
<p>Let us take a quick, focused look at how token costs arise and how providers bill them.</p>
<p>Every piece of text we send—the system prompt, tool definitions, conversation history, and user messages—becomes input tokens, while the model's response becomes output tokens. To the model, every call is a new input it has never seen before. Whether the conversation happened yesterday or the previous call was one minute ago, a new call sends the same content again in full as input. (This simple fact is the heart of token costs: the model has no memory, so we tell it everything again each time.)</p>
<p>One more variable enters the picture. Resending the static portion of a repeated call from scratch is expensive, so major LLM providers introduced prompt caching. Static input is stored in a cache once, and on subsequent calls, tokens read from that cache are billed at a much lower rate. (I covered the underlying mechanics—how BPE creates tokens and how the cache reuses the transformer's KV cache to lower the unit price—in How Tokens Work. Here, the focus is on how those mechanics translate into cost.)</p>
<h3 id="input-tokens-output-tokens-and-cache-tokens"><a class="anchor" href="#input-tokens-output-tokens-and-cache-tokens">Input Tokens, Output Tokens, and Cache Tokens</a></h3>
<p>Let us look at four fields in the <code>usage</code> object returned by the Anthropic SDK.</p>
<p><picture><source type="image/webp" srcset="/content/260611/1.720w.webp 720w, /content/260611/1.916w.webp 916w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/1.png" alt="The usage object from an Anthropic SDK response, showing input_tokens, cache_creation_input_tokens, cache_read_input_tokens, and output_tokens" width="916" height="638" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<ul>
<li><code>input_tokens</code> the portion of the submitted input excluding cache reads</li>
<li><code>output_tokens</code> the response generated by the model</li>
<li><code>cache_creation_input_tokens</code> tokens stored in the cache for the first time on this call</li>
<li><code>cache_read_input_tokens</code> tokens read again from an existing cache</li>
</ul>
<p>Each of these four fields is multiplied by a different rate. Output tokens are the most expensive, while tokens read from cache are the cheapest. According to Anthropic's official documentation, cache reads cost 0.1 times the base input rate—exactly 10%. Cache writes cost 1.25 times the base rate with a five-minute TTL (Time To Live, or cache validity period) and 2 times the base rate with a one-hour TTL. In other words, you pay slightly more for the first call, then save 90% from the second call onward.</p>
<p>Caching also has several less widely known constraints. The minimum cacheable token count varies by model and even by version within the same family. According to Anthropic's official documentation, Sonnet 4.6 and Opus 4.8 require 1,024 tokens, Opus 4.7 requires 2,048, and Haiku 4.5 plus the older Opus 4.5/4.6 require 4,096. A shorter prompt quietly goes uncached even if you add <code>cache_control</code>. A request can contain at most four <code>cache_control</code> breakpoints, and the cache is read hierarchically in the order <code>tools</code> → <code>system</code> → <code>messages</code>. As a result, changing even one tool definition near the front invalidates the entire cache that follows it.</p>
<p><img src="/content/260611/2.webp" alt="Prompt caching requires an exact match from the beginning; changing the prefix invalidates everything cached after it" width="1680" height="840" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>The 90% discount comes from reusing a transformer's KV cache across calls, a mechanism covered in detail in How Tokens Work. From a cost perspective, there is one key point to remember: a cache hit occurs only when the prefix matches exactly. Therefore, put <strong>static content first and dynamic content that changes on every call last</strong> so the cache stays intact. Even a single timestamp character near the beginning of a prompt invalidates the entire cache after it.</p>
<p>This naturally raises a question: “But the input changes on every turn in a multi-turn conversation. Wouldn't that break the cache almost every time?” The short answer is no. A conversation's input is not rewritten as a fresh block on each turn; it uses an <strong>append structure that preserves everything accumulated at the front and adds only the new utterance at the end</strong>. Once the system prompt, tool definitions, and previous questions and answers are fixed, they remain unchanged, and only the latest question is appended. Compare the current turn's input with the previous turn from the beginning, and the first difference will always be the new question at the very end. The full input changes every time, but most of the prefix stays the same, so the cache survives. (The cache breaks on every turn only in a flawed design that inserts dynamic values near the front of the prompt. Tools such as Claude Code and opencode are designed to keep the front fixed and append only at the end, so users do not need to configure anything separately for caching. Typos and spelling in the user's input do not affect the cache for the same reason: that variation always occurs at the very end, outside the cacheable prefix.)</p>
<h3 id="comparing-ai-providers"><a class="anchor" href="#comparing-ai-providers">Comparing AI Providers</a></h3>
<p>Putting the official Anthropic, OpenAI, and Google price lists side by side as of June 2026 makes the pattern immediately clear. (Prices are in USD per million tokens, focusing on models commonly used in everyday coding workflows.)</p>
<table>
<thead>
<tr>
<th>Provider</th>
<th>Model</th>
<th>input</th>
<th>cached input</th>
<th>output</th>
</tr>
</thead>
<tbody>
<tr>
<td>Anthropic</td>
<td>Claude Opus 4.8</td>
<td>$5.00</td>
<td>$0.50</td>
<td>$25.00</td>
</tr>
<tr>
<td>Anthropic</td>
<td>Claude Sonnet 4.6</td>
<td>$3.00</td>
<td>$0.30</td>
<td>$15.00</td>
</tr>
<tr>
<td>Anthropic</td>
<td>Claude Haiku 4.5</td>
<td>$1.00</td>
<td>$0.10</td>
<td>$5.00</td>
</tr>
<tr>
<td>OpenAI</td>
<td>GPT-5.5</td>
<td>$5.00</td>
<td>$0.50</td>
<td>$30.00</td>
</tr>
<tr>
<td>OpenAI</td>
<td>GPT-5.5 Pro</td>
<td>$30.00</td>
<td>$3.00</td>
<td>$180.00</td>
</tr>
<tr>
<td>Google</td>
<td>Gemini 3.1 Pro</td>
<td>$2.00</td>
<td>$0.20</td>
<td>$12.00</td>
</tr>
<tr>
<td>Google</td>
<td>Gemini 3.5 Flash</td>
<td>$1.50</td>
<td>$0.15</td>
<td>$9.00</td>
</tr>
</tbody>
</table>
<p>For all three providers, output tokens cost five to six times as much as input tokens. That means longer output quickly drives up the cost, even when the answer is substantively the same. Within the same tier, model prices also differ by a factor of three to six. The arithmetic alone points to three major principles for reducing token costs: <strong>request shorter output, use a cheaper model when it can produce the same answer, and cache static input</strong>. (One more calculation is revealing. Suppose you reuse a static context of 10,000 tokens 100 times. At GPT-5.5's base rate, that costs $5; as cache-read tokens, it costs $0.50. The cache therefore saves $4.50 across 100 reuses of the same context, and because the first call has almost no cache-write surcharge, the investment pays off from the second call.)</p>
<h3 id="how-cache-mechanisms-differ"><a class="anchor" href="#how-cache-mechanisms-differ">How Cache Mechanisms Differ</a></h3>
<p>Even when the discount rates are similar, the internal design differs by provider. Understanding why those differences exist makes policy decisions easier.</p>
<table>
<thead>
<tr>
<th>Item</th>
<th>Anthropic</th>
<th>OpenAI</th>
<th>Google Gemini</th>
</tr>
</thead>
<tbody>
<tr>
<td>Trigger</td>
<td>Explicit <code>cache_control</code> breakpoints</td>
<td>Automatic (no code changes needed)</td>
<td>Supports both automatic (implicit) and explicit caching</td>
</tr>
<tr>
<td>Minimum cache size</td>
<td>1,024–4,096 tokens (varies by model)</td>
<td>1,024+ (in increments of 128)</td>
<td>2,048–4,096 (varies by model)</td>
</tr>
<tr>
<td>Cache-write cost</td>
<td>1.25x (5 min) / 2x (1h) input rate</td>
<td>Free</td>
<td>Free (but hourly storage fees apply)</td>
</tr>
<tr>
<td>Cache-read cost</td>
<td>0.1x input rate</td>
<td>About 0.1x input rate</td>
<td>0.1x input rate</td>
</tr>
<tr>
<td>TTL</td>
<td><a href="https://github.com/anthropics/claude-code/issues/46829" target="_blank" rel="noopener noreferrer">5 minutes or 1 hour (user-selected)</a></td>
<td>5–10 minutes of inactivity by default, up to 1 hour (24 hours when extended)</td>
<td>User-defined (hourly storage fees apply)</td>
</tr>
<tr>
<td>Additional cost</td>
<td>None</td>
<td>None</td>
<td>Storage: Flash $1/M-hour, Pro $4.50/M-hour</td>
</tr>
</tbody>
</table>
<p>The design philosophy of each provider is easy to see. <strong>Anthropic</strong> asks users to mark cacheable sections explicitly, charges a modest premium on the first call (1.25 times), and then applies a deep discount. Because the prefix position is easy to control, the cache-read rate is predictable. <strong>OpenAI</strong> takes the opposite approach and automates everything. Inputs of at least 1,024 tokens are cached automatically at no additional cost, but users have less control over cache behavior. <strong>Google</strong> offers both approaches and charges a separate storage fee when users explicitly manage the cache. Implicit caching is advantageous for short, frequent reuse, while explicit caching plus storage fees suits large contexts that must remain available for an hour or more.</p>
<h3 id="tool-definitions-and-tokenizers"><a class="anchor" href="#tool-definitions-and-tokenizers">Tool Definitions and Tokenizers</a></h3>
<p>Two more variables have a surprisingly large impact.</p>
<p>The first is tool definitions. In an environment with several MCP servers attached, every call includes every tool name and parameter schema in the input—we will measure how much this inflates costs later. It is also worth noting that even with the exact same tools, changing only the model changes the cost. Anthropic's official pricing documentation shows that the tool system prompt itself differs in length by model. At <code>tool_choice: auto</code>, Sonnet 4.6 and Haiku 4.5 use about 497 tokens, Opus 4.7 uses 675 in the same position, and Opus 4.8 drops back to 290. Before model routing, checking how verbosely the current model expands tool definitions can reveal a surprisingly large difference.</p>
<p>The second is the efficiency of the tokenizer, the tool that breaks human-written text into token units the model can process. As discussed in How Tokens Work, the same text can produce different token counts depending on the tokenizer. OpenAI's o200k_base uses far fewer tokens than cl100k_base for non-English text, and Anthropic states that after switching to a new tokenizer beginning with Opus 4.7, the same text may be billed as up to 35% more tokens than with previous models. Choosing a model based only on price can allow costs to leak back in through tokenizer inefficiency. An honest comparison multiplies the unit price by the expected token count.</p>
<p>The difference is especially pronounced in Korean. When I tokenized the same sentences with two OpenAI tokenizers, the old and new versions produced identical token counts for English technical sentences, but the older cl100k_base used 31–43% more tokens for Korean than the newer o200k_base. One Korean paragraph was especially striking: 167 characters became 169 tokens with the older tokenizer, producing <strong>more tokens than characters</strong>. On average, each Korean character consumed more than one token.</p>
<p><picture><source type="image/webp" srcset="/content/260611/3.720w.webp 720w, /content/260611/3.1440w.webp 1440w, /content/260611/3.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260611/3.png" alt="Korean and English token counts by tokenizer: cl100k vs o200k" style="max-width:min(720px, 100%);height:auto;" width="1680" height="697" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The older tokenizer splits Korean more aggressively at the byte level, while the newer one treats frequently used chunks such as “개발” and “입니다” as complete tokens. That means even at the same unit price, a Korean-heavy workload can cost more than 1.5 times as much solely because of tokenizer efficiency.</p>
<p>At this point, another question arises naturally: “Where exactly are we creating all this inefficiency?”</p>
<h2 id="common-ways-tokens-get-wasted"><a class="anchor" href="#common-ways-tokens-get-wasted">Common Ways Tokens Get Wasted</a></h2>
<p>Token waste is surprisingly common even when we believe we are using AI well. Patterns I have observed in my own work and among other developers fall into several groups.</p>
<h3 id="too-many-mcp-servers-and-tool-definitions"><a class="anchor" href="#too-many-mcp-servers-and-tool-definitions">Too Many MCP Servers and Tool Definitions</a></h3>
<p>I mentioned this in the previous section, but it bears repeating. Once people attach MCP servers such as Linear, GitHub, Notion, Figma, Slack, and Sentry, they rarely remove them. Unused tool schemas inflate the input token count on every call. To address this, Claude Code enables MCP Tool Search by default: only tool names and server descriptions enter the context at the beginning of a session, and the full schema is loaded only when the model actually calls that tool.</p>
<p>I measured how large the difference could be. Using 27 MCP tools from a Claude Code session I was working in—10 from Serena, eight across four claude.ai OAuth integrations, two from Figma, and seven from agentmemory—I sent <strong>the same user message under two configurations</strong>. One had no MCP servers installed. The other had all 27 attached but unavailable for the model to call. In both cases, the number of tool calls was zero.</p>
<p><picture><source type="image/webp" srcset="/content/260611/6.720w.webp 720w, /content/260611/6.1440w.webp 1440w, /content/260611/6.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/6.png" alt="The same question called with no MCP servers and with 27 attached: input tokens go from 41 to 10,335 and the per-call cost rises about 11.7 times" width="1680" height="638" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>With the same question, the same model, and an answer with the same meaning, input tokens alone rose from <strong>41 → 10,335 (+10,294)</strong> on Opus 4.7. The cost of a single call increased from <strong>$0.0048 → $0.0563, about 12x</strong>. The 250-fold increase in input also raised prefill load, adding <strong>+783ms</strong> to response time. More striking than the dollar amount was that this was <strong>a cost paid on every call even though the user did not invoke a single MCP tool on that turn</strong>. This is the overhead that Tool Search prevents. (Since taking this measurement, I have continuously pruned MCP servers I do not use regularly.)</p>
<h3 id="context-accumulation-and-lost-in-the-middle"><a class="anchor" href="#context-accumulation-and-lost-in-the-middle">Context Accumulation and Lost in the Middle</a></h3>
<p><picture><source type="image/webp" srcset="/content/260611/4.720w.webp 720w, /content/260611/4.840w.webp 840w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/4.jpg" alt="The first page of the &#x22;Lost in the Middle&#x22; paper, showing the U-shaped curve where accuracy bottoms out when the answer sits in the middle of the context" width="840" height="727" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Carrying a long conversation forward does more than increase input tokens per call; it also lowers the model's accuracy. The “Lost in the Middle” paper by Liu's Stanford research team quantified a U-shaped curve: models retrieve key information best when it appears at the beginning or end of the context, and perform noticeably worse when it is buried in the middle. It is the worst possible combination—spending more tokens for a poorer answer. Because a transformer's self-attention computation grows with the square of the token count, the absolute share of attention available to each token becomes more diluted as context length increases. The middle weakens first because training distributions tend to place important information near the beginning and end.</p>
<p>Looking one level deeper, the model's treatment of token “position” contains two tendencies that bury the middle. They may sound technical, but the intuition is straightforward.</p>
<p>First, the model <strong>listens more closely to nearby tokens</strong>. RoPE (Rotary Position Embedding), the positional representation used by most current open models, is designed so that two tokens connect more weakly as the distance between them increases—a decay effect in which the signal fades with distance. Distant tokens therefore receive less attention naturally.</p>
<p>Second, the model <strong>sends an unnecessary amount of attention to the very first token</strong>. At every moment, the model must distribute a full 100% of its attention—the softmax forces attention weights to sum to 1. When there is nothing in particular to focus on, the remaining attention must be discarded somewhere, and that destination is usually the first token in the sequence. This phenomenon, in which the first token absorbs surplus attention, is called an attention sink. (Xiao's MIT research team first quantified this structure in the StreamingLLM paper. It is less a sign that the first token contains important information than a byproduct of using it as a drain for spare attention.)</p>
<p>Together, these two tendencies concentrate attention at both ends—the nearby recent tokens and the first token—while information buried in the middle receives the weakest treatment. Importantly, this is not a bug in one particular model. Most open models, including LLaMA, Mistral, and Qwen, use RoPE-family mechanisms, and proprietary models such as Claude and GPT are believed to use similar approaches. Lost in the middle is therefore closer to a shared bias in modern transformer architecture.</p>
<p><picture><source type="image/webp" srcset="/content/260611/5.720w.webp 720w, /content/260611/5.1200w.webp 1200w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/5.png" alt="A context rot chart: as input length grows, accuracy falls for Claude, Qwen, OpenAI, and Gemini alike" width="1200" height="622" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>More recently, this phenomenon has been called <strong>context rot</strong>. An <a href="https://research.trychroma.com/context-rot" target="_blank" rel="noopener noreferrer">analysis by the Chroma research team</a> put 18 frontier models—including GPT-4.1, Claude 4, Gemini 2.5, and Qwen3—through the same NIAH (needle in a haystack) task. It quantified accuracy declines of 20–50%, depending on the model, when the input grew from 10k to more than 100k tokens. All 18 models performed worse as context length increased, with the Claude family declining most gradually. Anthropic also explains this as an “attention budget” problem arising from the transformer's n² attention, with the budget being depleted across tokens. Keeping context light therefore reduces costs and protects accuracy at the same time.</p>
<h3 id="calling-subagents-indiscriminately"><a class="anchor" href="#calling-subagents-indiscriminately">Calling Subagents Indiscriminately</a></h3>
<p>Delegating every task just because subagents are useful is another trap. A subagent starts in a separate context from its parent, so it pays the fixed cost of loading the system prompt and tool definitions from scratch. If you delegate a lightweight task such as a short shell command or a simple git lookup, that startup cost can exceed any savings from keeping the main context clean. According to Anthropic's published report on its multi-agent research system, a single agent uses roughly four times as many tokens as ordinary chat, while a multi-agent system uses roughly 15 times as many. Delegation makes sense only when the accuracy gain justifies that four- to fifteen-fold overhead.</p>
<p>The same trap applies to attaching MCP tools. Every enabled tool adds its schema to the system prompt on every turn, and the cost is billed even on turns when the model does not use the tool. “Enable everything just in case” may feel intuitively safer, but actual measurements tell a different story.</p>
<p>I tested both accuracy and cost on the same batch of questions about the reconciler source in facebook/react v19, divided into three setups:</p>
<ul>
<li>No tools</li>
<li>One tool (CodeGraph, Serena, ripgrep, or bare grep)</li>
<li>All four tools attached at once</li>
</ul>
<p><picture><source type="image/webp" srcset="/content/260611/9.720w.webp 720w, /content/260611/9.1440w.webp 1440w, /content/260611/9.1680w.webp 1680w" sizes="(max-width: 532px) calc(100vw - 32px), 500px"><img src="/content/260611/9.png" alt="Recall and token usage by tool scenario in the codesearch benchmark" style="max-width:min(500px, 100%);height:auto;" width="1680" height="1927" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Three findings stood out. (Here, recall means how completely the correct answers were found.)</p>
<ul>
<li><strong>No tools</strong> achieved an average recall of only 0.31, confirming that tools themselves clearly provide value.</li>
<li><strong>Serena (LSP) alone</strong> achieved 1.00 recall at a cost of $0.38, making it the most efficient of all single-tool strategies.</li>
<li><strong>All four tools attached</strong> reduced recall to 0.89 while raising the cost to $0.47. On multi-hop questions in particular, the all-tools score matched CodeGraph alone (0.78 / 0.88) to the second decimal place. The model gravitated toward one of the four tools and inherited that tool's weaknesses.</li>
</ul>
<p>One principle applies equally to subagents and tool attachment: the extra cost matters only when an accuracy improvement justifies it. <strong>Choosing the one tool that fits the task domain is cheaper and more accurate than enabling everything just in case.</strong></p>
<p>So what are the proven ways to save tokens? Do effective methods really exist?</p>
<h2 id="proven-ways-to-save-tokens"><a class="anchor" href="#proven-ways-to-save-tokens">Proven Ways to Save Tokens</a></h2>
<p>Each savings pattern attacks a different cost axis. Some reduce input, some lower the unit price of the same input, and some assign the same work to a cheaper model. Let us examine them one by one.</p>
<h3 id="prompt-caching"><a class="anchor" href="#prompt-caching">Prompt Caching</a></h3>
<p>This produces the most immediate effect. As discussed above, cache reads cost 0.1 times the base input rate. After paying a small write premium of 1.25 times on the first call, you can reuse the same static section from the second call onward at one-tenth the price.</p>
<p>From a cost-saving perspective, one point deserves emphasis: under any caching approach, <strong>the parts that remain nearly identical across calls</strong>—tool definitions, code snippets, and RAG context—are exactly what the cache can capture. The benefit remains intact as long as those static blocks are not mixed with dynamic content such as the current turn's question or a tool result that just arrived. If you call the API directly, grouping static blocks at the front and moving dynamic parts behind them delivers most of the savings. If you use a finished tool such as Claude Code or opencode, the tool handles that ordering for you.</p>
<h3 id="batch-asynchronous-work-with-the-batch-api"><a class="anchor" href="#batch-asynchronous-work-with-the-batch-api">Batch Asynchronous Work with the Batch API</a></h3>
<p>When a call does not need to finish immediately, the Batch API is the simplest way to lower the rate itself. Anthropic, OpenAI, and Google all apply the same 50% discount to both input and output tokens in exchange for returning results within 24 hours. With Anthropic, most batches finish within an hour in practice, but the SLA itself is 24 hours. The calls remain unchanged while the unit price is cut in half, so the implementation cost is minimal.</p>
<p>What makes that 50% especially interesting is that it compounds multiplicatively with other savings. Anthropic's official documentation explicitly states that caching and batch discounts stack. For static input, the calculation is standard rate × 0.5 (batch) × 0.1 (cache read) = 0.05 times, meaning the static portion can fall to 5% of the standard rate. The key is that the discounts multiply rather than add.</p>
<p>I calculated the difference directly. Suppose 100 jobs each contain 10,000 tokens of static context, 500 tokens of dynamic input, and 1,000 output tokens. Using Opus 4.8 pricing, I compared the cost of four strategies.</p>
<p><picture><source type="image/webp" srcset="/content/260611/12.720w.webp 720w, /content/260611/12.1440w.webp 1440w, /content/260611/12.1680w.webp 1680w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260611/12.png" alt="Token cost savings from Batch API and Prompt caching: the two discounts compound multiplicatively" style="max-width:min(720px, 100%);height:auto;" width="1680" height="670" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Caching alone cuts the cost by 57%, batching alone by 50%, and using both by as much as 79%. The discounts multiply over the static-input portion rather than simply adding together. (Output tokens are not cacheable and receive only the 50% batch discount, so workloads with a high share of output see a total reduction smaller than 79%. The larger the static-input share, the stronger the compounding effect.) There are more areas than one might expect where nobody needs to wait in front of a screen: overnight codebase indexing, pre-publication article evaluation, data extraction, and recurring reports.</p>
<p>Not every task can be deferred to asynchronous processing, however. Batch does not fit work where response time itself creates value, such as the main session of a coding agent, where the user reads an answer before choosing the next action, or an interactive chat UI. Simply dividing work into two lanes—“results needed immediately” and “results needed by the next workday”—can cut the bill in half.</p>
<h3 id="isolate-verbose-work-with-a-subagent"><a class="anchor" href="#isolate-verbose-work-with-a-subagent">Isolate Verbose Work with a Subagent</a></h3>
<p><img src="/content/260611/7.webp" alt="Subagent context-isolation structure" style="max-width:min(500px, 100%);height:auto;" width="1680" height="1680" loading="lazy" fetchpriority="low" decoding="async"></p>
<p><a href="https://www.anthropic.com/engineering/multi-agent-research-system" target="_blank" rel="noopener noreferrer">To use the wording from Claude Code's official documentation,</a> a subagent “operates in its own isolated context window, with intermediate tool calls and results remaining inside the subagent and only the final message returning to the parent.” Delegating an entire verbose task therefore leaves only a clean summary in the parent context. According to <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic's article on context engineering</a>, even when a subagent spends tens of thousands of tokens exploring, it typically returns a compressed summary of only 1,000–2,000 tokens to the parent. The benefit of keeping the parent context light is clear.</p>
<blockquote>
<p>A verbose task is one that produces a vast number of tokens in the process of finding a one-line answer. Running tests, searching documentation, and analyzing logs are typical examples.</p>
</blockquote>
<p>One misconception needs clarification: a subagent does not automatically reduce total cost. Anthropic's own report says agents use about four times as many tokens as ordinary chat and multi-agent systems about 15 times as many. A subagent protects accuracy and the cost of an expensive long-running parent context by removing verbose output from it; it is not magic that always reduces total token expenditure. That is why the earlier principle still applies: delegate only when the cleanup savings in the main context exceed the startup cost. For short tasks, it is cheaper for the parent to handle them directly.</p>
<p>There is also a domain consideration. Anthropic's multi-agent research system report says that in its internal evaluation, an Opus 4 leader with Sonnet 4 subagents improved performance by 90.2% over a single Opus 4 agent. That improvement, however, is not uniform across domains. In the same report, Anthropic explicitly says that domains where agents must share the same context or have many interdependencies are poor fits for multi-agent systems—and identifies coding as exactly such a case. Research can explore independent directions in parallel, but code operates on a dependency graph where changing one function affects another. (If you use a multi-agent setup for coding, it is worth asking whether the workflow truly resembles parallel exploration. A single agent plus one isolated exploration subagent may be a safer default for the coding domain.)</p>
<h3 id="compact-and-progressive-disclosure"><a class="anchor" href="#compact-and-progressive-disclosure">compact and Progressive Disclosure</a></h3>
<p>Claude Code's <code>/compact</code> command compresses the entire conversation so far into a summary and restarts with a fresh context. According to Anthropic's official explanation, compaction is not simple truncation but semantic summarization: it retains the context of ongoing work and recent changes while discarding material unlikely to be referenced again, such as repetitive tool output. Unlike <code>/clear</code>, which removes everything, <code>/compact</code> preserves a summary. When the context window approaches 95% full, auto-compact performs the same operation automatically. Tidying a long session at an appropriate point stops input tokens from accumulating.</p>
<p>Looking further inside, <code>/compact</code> is only the final stage a user can invoke explicitly; before it, a four-stage context-compression pipeline runs automatically. According to <a href="https://github.com/VILA-Lab/Dive-into-Claude-Code" target="_blank" rel="noopener noreferrer">an external study of Claude Code's internals, “Dive into Claude Code,”</a> <code>query.ts</code> checks the following five stages in order before every call.</p>
<p><picture><source type="image/webp" srcset="/content/260611/8.720w.webp 720w, /content/260611/8.1440w.webp 1440w, /content/260611/8.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/8.png" alt="An agent loop diagram: it starts from the user prompt, repeats tool requests and permission checks, and passes through compaction when context pressure builds" width="1680" height="1591" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<ul>
<li><strong>Budget Reduction</strong> truncates portions of individual tool outputs that exceed their size limit.</li>
<li><strong>Snip</strong> cuts off older history along the time axis.</li>
<li><strong>Microcompact</strong> performs fine-grained compression while preserving cache awareness.</li>
<li><strong>Context Collapse</strong> projects extremely long history again at read time to reduce its dimensionality.</li>
<li><strong>Auto-Compact</strong> triggers semantic compression at the 95% mark as a last resort.</li>
</ul>
<p>The higher stages are lighter and cheaper; the lower ones are heavier but more effective. This five-stage design reflects the recognition that no single compression strategy can resolve every kind of context pressure. Explicitly invoking <code>/compact</code> is roughly equivalent to triggering the final stage of this automatic pipeline early.</p>
<p>The same way of thinking appears in Claude Code's Skills architecture. Where <code>/compact</code> reduces context after it has accumulated, the three-stage loading used by <code>/skills</code> prevents that context from accumulating in the first place.</p>
<p>According to Anthropic's documentation, a skill loads in three stages. Only its name and one-line description—about 100 tokens—enter the context at session start. Its body (<code>SKILL.md</code>, fewer than 5,000 tokens) loads only when the skill is triggered. When bundled scripts or resources are executed through bash, only their output returns; the code itself never enters the context. Installing dozens of skills therefore adds almost nothing to the initial context.</p>
<h3 id="model-routing-use-a-cheaper-model-for-the-same-answer"><a class="anchor" href="#model-routing-use-a-cheaper-model-for-the-same-answer">Model Routing: Use a Cheaper Model for the Same Answer</a></h3>
<p><picture><source type="image/webp" srcset="/content/260611/13.720w.webp 720w, /content/260611/13.1172w.webp 1172w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260611/13.png" alt="Model performance plotted against log cost, with the ideal router sitting at the low-cost, high-performance corner" width="1172" height="1039" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The input price of Opus 4.8 is five times that of Haiku 4.5. Using the largest model for every simple search, exploration, or short summary is a major waste of money. Routing by task difficulty—Haiku → Sonnet → Opus—and calling Opus only for genuinely reasoning-heavy stages is becoming standard practice. <a href="https://lmsys.org/blog/2024-07-01-routellm/" target="_blank" rel="noopener noreferrer">LMSYS's RouteLLM research</a> demonstrated a router that preserved 95% of GPT-4 quality while reducing strong-model calls to 14%, though the benchmark measured general reasoning rather than coding specifically.</p>
<p>The tooling landscape has also settled quickly. Commercial gateways include OpenRouter, Martian, and NotDiamond, while the open-source self-hosted ecosystem includes LMSYS's RouteLLM and cost-observability layers such as LiteLLM and Bifrost. Within the Anthropic ecosystem, the Agent SDK's model-selection argument, the Claude Code subagent <code>model</code> field—Explore defaults to Haiku—and the <code>/model</code> slash command all support this kind of routing. One caveat matters: when you call an API directly, it does not inspect input difficulty and choose a model automatically. The Anthropic, OpenAI, and Google APIs all use exactly the model name the user specifies by default. Automatic routing is an optional product-level feature, available in places such as Cursor's Auto, ChatGPT's auto mode, and OpenRouter's <code>openrouter/auto</code>. To save money through routing, you must build it yourself, whether with a gateway or a classifier.</p>
<p>That does not mean adding an external automatic router indiscriminately is the answer. A router such as OpenRouter's Auto Router, which dynamically changes the model on each call, conflicts directly with prompt caching. Anthropic's ephemeral cache hits only when the same model and the same prefix match. If the model key changes on every call, the cache key misses every time. You lose the 90% discount from cache reads, and it is common to save nearly half through routing only to give the money back through cache misses. This is why OpenRouter recognizes the issue and recommends a <code>session_id</code>-based stickiness option.</p>
<p>The pattern that production teams have converged on is surprisingly simple: do not classify dynamically on every call; route statically by task type. The subagent <code>model</code> field discussed earlier follows this pattern. An Explore subagent always uses a Haiku-powered code-exploration lane, while a code-review subagent always uses an Opus-powered review lane. Within each lane, the same model, system prompt, and tool definitions repeat, allowing every model to accumulate and hit its own separate cache. When people say “routing is becoming standard,” they increasingly mean static task-type branching in a “coordinator + executor” structure, not a classifier that runs on every call. That is how routing and caching can coexist without contradiction.</p>
<h3 id="cursor-composer-25"><a class="anchor" href="#cursor-composer-25">Cursor Composer 2.5</a></h3>
<p><img src="/content/260611/10.webp" alt="Cursor&#x27;s published chart of CursorBench 3.1 score against average cost per task, with Composer 2.5 approaching top scores at the cheapest end" width="1680" height="1120" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>This is a slightly different way to save, but one option is to use an agent such as Cursor. <a href="https://cursor.com/blog/composer-2-5" target="_blank" rel="noopener noreferrer">Composer 2.5, Cursor's proprietary model released on May 18, 2026,</a> is based on Moonshot AI's open-source Kimi K2.5 checkpoint and fine-tuned specifically for coding. In Cursor's own benchmarks, the team says it delivers coding performance comparable to Claude Opus 4.7 at roughly one-tenth the price. Published base pricing is $0.50 for input and $2.50 for output, exactly one order of magnitude below Opus 4.8's $5.00 input and $25.00 output rates.</p>
<p>Because Cursor measured the specialized model's benchmark itself, it is safer not to treat the absolute figures as definitive. The broader trend is what matters: a smaller model trained specifically for coding can deliver practically comparable performance to a general-purpose frontier model on the same work while reducing costs by an order of magnitude. This has become one of the clearest cost-saving trends of 2026.</p>
<p>The truly interesting part is that this approach does not merely reduce the unit price: <strong>when the model is co-designed with an IDE, it also reduces the input token count itself</strong>. Models such as Composer and Cascade are trained to consume codebase context sent by the IDE—current files, adjacent files, and indexed symbols—efficiently. On the same task, that reduces the input-token inflation caused when a general-purpose model repeatedly asks to see relevant files and performs more grep and read operations. The unit price drops by an order of magnitude and the token count falls slightly as well, so real savings can exceed the price difference alone.</p>
<h3 id="move-context-outside-the-context"><a class="anchor" href="#move-context-outside-the-context">Move Context Outside the Context</a></h3>
<p>One of the most interesting trends of 2026 is loading “only what is needed, when it is needed” into context. Anthropic calls this <strong>just in time (JIT) context</strong>. Instead of placing every resource in the context in advance, the agent carries lightweight references such as file paths or queries and retrieves the actual material with a tool only when necessary. Claude Code follows this pattern by reading files on demand with glob and grep rather than indexing and loading the entire codebase into context at once.</p>
<p>The memory tool and context editing that Anthropic released with Sonnet 4.5 follow the same philosophy. One distinction is important: the “memory” in the memory tool differs from what people commonly imagine. It is neither the conversation history that accumulates within a session—remaining in the context window and disappearing when the session ends—nor a configuration file such as <code>CLAUDE.md</code> that a person writes in advance and the tool loads automatically at startup. The memory tool is <strong>a tool through which the model directly writes and reads files</strong>.</p>
<p>At a deeper level, the two tools expose surprisingly simple interfaces. The memory tool lets Claude create, read, update, and delete files in a dedicated memory directory hosted in the user's infrastructure. In other words, the model handles notes like an ordinary file system rather than being told to “write this down somewhere in the text.” Keeping storage in user infrastructure is the crucial design choice. It gives Claude persistent memory outside the context window that can carry across sessions, while letting the user control how the data is stored and retained. Session memory is volatile inside the context; this memory remains outside it and survives into the next session. Context editing does the same work in the opposite direction. As the token limit approaches, it automatically clears old tool-call results that are no longer referenced, preserving the conversation flow while allowing the agent to run longer.</p>
<p>In Anthropic's own evaluation, using both tools improved performance by 39% over the baseline, while context editing alone improved it by 29%. In a 100-turn web-search evaluation, token consumption fell by 84%. (These are vendor benchmarks and should be read accordingly, but the direction is clear: designing context to be emptied matters more and more than filling it.) This trend aligns with Claude Code's five-stage <code>/compact</code> pipeline. The difference is that <code>/compact</code> reduces what is inside the context, while the memory tool creates separate storage outside it. The two approaches complement rather than conflict with each other.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p><img src="/content/260611/14.webp" alt="Prompt engineering next to context engineering: a single-turn query carries only a system prompt and user message, while an agent curates documents, tools, and memory and feeds tool results back" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Viewed together, token savings ultimately reduce to three axes: <strong>send less for the same work, pay less for the same input, and assign the same answer to a cheaper model</strong>. Prompt caching, Batch API, subagent isolation, <code>/compact</code>, the memory tool and context editing, model routing, and specialized models differ only in which of these three axes they attack. If the direction of 2026 had to be summarized in one sentence, it would be this: the center of gravity is shifting from the technology of filling context to the technology of emptying and selecting it—context engineering. As context rot demonstrates, a lighter context improves not only cost but accuracy.</p>
<p>There is no single correct answer, of course. Every team has different work patterns, and even one person's cost structure changes between writing prose and writing code. One thing is certain: as the ecosystem moves quickly, assuming that “a savings pattern that worked yesterday will still work today” is becoming increasingly risky. Keeping up with new models, tools, and price lists—and building a habit of validating them through a small POC in your own workflow—may ultimately be the least conspicuous yet most durable way to save. I encourage readers to take out their own token ledger at least once. (If you want to understand why these strategies work at the root level, read the discussion of BPE and KV cache in How Tokens Work as well.)</p>
<p>One question remains. Now that we have techniques for emptying and selecting context, what comes next? Following the available research, the focus is moving one step beyond context to the agent system as a whole: how to operate it (harness design), how to measure whether it operated well (eval), and how to confine it when it goes wrong (containment). Interestingly, when the “measure it yourself with a POC” mindset emphasized throughout this article scales to the system level, it becomes exactly an eval. I plan to explore that next direction in detail in <a href="/260622" target="_blank" rel="noopener noreferrer">Beyond Context</a>.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic Prompt Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/build-with-claude/batch-processing" target="_blank" rel="noopener noreferrer">Anthropic Batch Processing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/about-claude/pricing" target="_blank" rel="noopener noreferrer">Anthropic Claude API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://code.claude.com/docs/en/sub-agents" target="_blank" rel="noopener noreferrer">Anthropic Claude Code Subagents</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.claude.com/en/docs/claude-code/mcp" target="_blank" rel="noopener noreferrer">Anthropic Claude Code MCP Tool Search</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview" target="_blank" rel="noopener noreferrer">Anthropic Agent Skills</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developers.openai.com/api/docs/guides/prompt-caching" target="_blank" rel="noopener noreferrer">OpenAI Prompt Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://openai.com/api/pricing/" target="_blank" rel="noopener noreferrer">OpenAI API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://ai.google.dev/gemini-api/docs/caching" target="_blank" rel="noopener noreferrer">Google Gemini Context Caching</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://ai.google.dev/pricing" target="_blank" rel="noopener noreferrer">Google Gemini API Pricing</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.anthropic.com/news/context-management" target="_blank" rel="noopener noreferrer">Anthropic, Managing context on the Claude Developer Platform</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2601.07206" target="_blank" rel="noopener noreferrer">LLMRouterBench (Findings of ACL 2026)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2604.14228" target="_blank" rel="noopener noreferrer">VILA-Lab, Dive into Claude Code</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Tokens</category>
        </item>
        <item>
            <title><![CDATA[How Tokens Work]]></title>
            <link>https://hooninedev.com/en/260610</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260610</guid>
            <pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to explore what AI tokens actually are and how they work. Until now, I have mainly written about how to use AI tools effectively, which tools are gaining popularity, and why. But ...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to explore what AI tokens actually are and how they work.</p>
<p>Until now, I have mainly written about how to use AI tools effectively, which tools are gaining popularity, and why. But while putting together my guide to <a href="/260611" target="_blank" rel="noopener noreferrer">saving tokens</a>, I was reminded of something important: before explaining how to reduce costs, I first needed to explain what tokens are and how they are billed. Yet I had never properly covered that foundation. (As I wrote the cost-saving guide, the explanation of how tokens work grew substantial enough to become a post of its own.)</p>
<p>This post, then, lays the groundwork before we get into cost-saving techniques. We will go through what tokens actually are if they are neither words nor characters (BPE), what form they take when they enter a model (embeddings), why output is more expensive than input (prefill/decode), where inside the Transformer architecture prompt caching gets its cost advantage (KV cache), and why longer contexts become more expensive (the quadratic cost of attention). If you are looking for practical ways to save, continue with How to Save Tokens after this article.</p>
<h2 id="tokens-are-neither-words-nor-characters"><a class="anchor" href="#tokens-are-neither-words-nor-characters">Tokens Are Neither Words nor Characters</a></h2>
<p>Let us start with the most fundamental fact: <strong>a token is neither a word nor a character.</strong> It is a unit in a vocabulary built by compressing character sequences that occur frequently in the model's training data.</p>
<p>Our intuition for asking “How many words are in this sentence?” differs from the way a model splits text. A model groups character combinations that frequently appear together into single units, then segments incoming text according to that vocabulary. As a result, one word may be represented by a single token while another is split into several.</p>
<p>The algorithm used to build this vocabulary is BPE, a common foundation of nearly every modern LLM.</p>
<h3 id="the-bpe-algorithm"><a class="anchor" href="#the-bpe-algorithm">The BPE Algorithm</a></h3>
<p>BPE (Byte Pair Encoding) is an algorithm that grows a vocabulary by repeatedly merging the most frequent pair of adjacent symbols into a new symbol.</p>
<p>Interestingly, it was not originally created for natural language processing. Philip Gage first proposed BPE in 1994 as a <a href="/240706" target="_blank" rel="noopener noreferrer">data compression</a> technique. The method replaced the most frequently occurring byte pair in a dataset with a single byte not otherwise used in the data, then stored those replacement rules separately in a table.</p>
<p>A research team led by Sennrich at the University of Edinburgh brought the technique to the vocabulary problem in neural machine translation. In “Neural Machine Translation of Rare Words with Subword Units,” released in 2015 and presented at ACL 2016, they used BPE to overcome the inability of a fixed vocabulary to handle rare or previously unseen words. The idea was that instead of memorizing whole words, a model could represent them as combinations of smaller subword pieces, allowing even out-of-vocabulary words to be encoded from known parts.</p>
<p>The process itself is surprisingly simple.</p>
<ol>
<li>Initially, each individual character is one token.</li>
<li>Find the pair of tokens that appears adjacent most often in the corpus.</li>
<li>Merge that pair into a new token, then replace every occurrence of the pair in the corpus with the new token.</li>
<li>Repeat steps 2–3 until the vocabulary reaches its target size.</li>
</ol>
<p>At first, each character is its own token. Over time, frequently occurring combinations such as “th,” “the,” and “tion” are merged into single tokens. In other words, the more common a pattern is, the more likely it is to become a larger unit.</p>
<p><img src="/content/260610/2.webp" alt="How a BPE tokenizer grows its vocabulary by repeatedly merging frequent adjacent character pairs" style="max-width:min(500px, 100%);height:auto;" width="1024" height="1536" loading="eager" fetchpriority="high" decoding="async"></p>
<p>The GPT family takes this one step further with <strong>byte-level BPE</strong>. It first converts text into a stream of UTF-8 bytes, then applies BPE. This allows the base vocabulary to start with only 256 entries—one for each possible byte—while still being able to encode any text representable in UTF-8 without an unknown token. Whether the input contains emoji, Chinese characters, or special symbols, it is always representable at least at the byte level.</p>
<h3 id="same-text-different-token-counts"><a class="anchor" href="#same-text-different-token-counts">Same Text, Different Token Counts</a></h3>
<p>Because of this structure, the same text can produce very different token counts depending on the tokenizer, the tool that splits human-written text into tokens a model can process. Depending on how its vocabulary was trained, a tokenizer may split the same sentence into finer pieces or larger chunks.</p>
<p><picture><source type="image/webp" srcset="/content/260610/3.720w.webp 720w, /content/260610/3.1440w.webp 1440w, /content/260610/3.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/3.png" alt="A comparison showing that the same text produces different token counts across tokenizers; o200k_base uses fewer tokens than cl100k_base for non-English text" width="1680" height="997" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The material OpenAI published alongside GPT-4o illustrates this difference clearly. The o200k_base tokenizer used by the GPT-4o family represents the same text with fewer tokens than cl100k_base, the tokenizer used by the GPT-4 family. It is only about 1.1 times as efficient for English, but the gap widens for other languages. In the short examples OpenAI published, token counts fell by about 1.4 times for Chinese and Japanese, 1.7 times for Korean, and as much as 2.9 times for Hindi. (For Korean users, it is striking how much more noticeable the difference between tokenizer generations is than it is for English users.)</p>
<p>Looking more broadly, there are also disparities between languages themselves. In Yennie Jun's comparison of multiple languages using cl100k_base, English almost always required the fewest tokens for sentences with the same meaning, while languages with their own writing systems tended to require more. Hindi and Bengali could use five times as many tokens as English, and Burmese more than ten times as many. Korean and Chinese also tokenize into more pieces than English, though not as extremely as those languages.</p>
<p>This has an important practical implication. When Anthropic introduced a new tokenizer starting with Opus 4.7, its official pricing documentation stated that the same text could be billed as up to 35% more tokens than with previous models. <strong>Even when the per-token rate stays the same, the bill rises in direct proportion to the token count.</strong> An honest model comparison therefore has to consider not just the listed rate, but the rate multiplied by the expected number of tokens.</p>
<p>But when these segmented tokens enter the model, they do not go in as literal characters or words. So what form does a token take as model input?</p>
<h2 id="how-tokens-enter-the-model"><a class="anchor" href="#how-tokens-enter-the-model">How Tokens Enter the Model</a></h2>
<p>Neural networks cannot work with text directly. Ultimately, a model can compute only with numbers—more precisely, with vectors, which are arrays of numbers. Before entering the model, tokens therefore pass through several stages that convert them into numerical form.</p>
<p>Here is the sequence.</p>
<ol>
<li><strong>Text → tokens</strong>: A BPE tokenizer splits the text into token pieces.</li>
<li><strong>Token → token ID</strong>: Each token maps to an integer ID indicating its place in the vocabulary. For example, if <code>" the"</code> is the 1,234th token in the vocabulary, its ID is <code>1234</code>.</li>
<li><strong>Token ID → embedding vector</strong>: The ID selects the corresponding row from the embedding matrix. This matrix is a huge table with dimensions <code>vocabulary size × model dimension (d_model)</code>: each token ID corresponds to one row, or one vector. You can think of this vector as a set of coordinates that captures the token's “meaning.”</li>
<li><strong>Add positional information</strong>: On its own, self-attention has no awareness of token order. In other words, it would view “I like you” and “You I like” as identical. Positional information is therefore injected into each token vector to tell the model the sequence order.</li>
</ol>
<p>That can feel abstract in words alone, so I have included a widget that lets you interact with all four stages. Change the input sentence or click a token, and you can follow its path from ID to embedding vector, then see positional information added to produce the final input vector. (Only a few dimensions are displayed, but keep in mind that the actual GPT-4 vector has 12,288 dimensions.)</p>
<p>Here, the model dimension (d_model) is the length of the vector used to represent one token. The original Transformer paper used a value of 512, but modern LLMs are much larger. (Even the 7B version of Llama 2 uses 4,096 dimensions.) A larger model dimension can represent subtle differences in token meaning more richly, but it also increases computation and memory requirements.</p>
<p><picture><source type="image/webp" srcset="/content/260610/4.704w.webp 704w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/4.png" alt="How a token ID looks up one row of the embedding matrix and becomes a d_model-dimensional vector" width="704" height="435" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Methods of injecting positional information have also changed across generations. The original Transformer added position vectors built from sine and cosine functions directly to the embeddings. RoPE (Rotary Position Embedding), now the de facto standard in open LLMs, instead injects relative position during the attention calculation by rotating the Query and Key vectors. (The mechanism differs, but the purpose is the same: giving the Transformer a sense of order.)</p>
<p>In short, the flow is <strong>text → tokens → token IDs → embedding vectors (+ positional information) → Transformer input</strong>. The “token count” on a bill refers to the second stage of this pipeline: how many tokens the text was split into.</p>
<p>At this point, we know how tokens are created and what form they take when they enter a model. But when the model processes them, why do input and output cost so differently, and how can the cost of sending the same input repeatedly be reduced?</p>
<h2 id="why-input-is-cheap-and-output-is-expensive"><a class="anchor" href="#why-input-is-cheap-and-output-is-expensive">Why Input Is Cheap and Output Is Expensive</a></h2>
<p>Anyone who has worked with tokens has probably wondered about this at some point: on pricing tables, <strong>output tokens cost several times more than input tokens.</strong> For Anthropic, the output rate is exactly five times the input rate across every model. (Opus is $5 for input and $25 for output, while Haiku is $1 for input and $5 for output.) Why should the same token have a different price depending on whether it is going in or coming out?</p>
<p>The answer is that models process tokens in completely different ways for input and output. LLM inference has two stages.</p>
<ul>
<li><strong>prefill (input processing)</strong>: The model processes all prompt tokens <strong>at once and in parallel</strong>. Whether the prompt contains 1,000 or 10,000 tokens, the GPU sweeps through them together and calculates the K/V (Key/Value) vectors for each token. The total computation is substantial, but parallel processing makes it efficient per token.</li>
<li><strong>decode (output generation)</strong>: The model generates response tokens <strong>sequentially, one at a time</strong>. After producing one token, it appends that token to the input and generates the next. This repeats until the response is complete.</li>
</ul>
<p>That asymmetry creates the cost difference. According to Databricks' analysis of inference performance, prefill is a compute-bound stage, while decode is memory-bound. During decode, the model has to read all of its weights from GPU memory again for every token it produces, yet each pass yields exactly one token. Most of the GPU's enormous compute capacity sits idle. (To borrow Databricks' phrasing, you are paying to keep the GPU running without using its available compute.)</p>
<p>In other words, <strong>input tokens are processed efficiently together in parallel during prefill, while output tokens must be squeezed out inefficiently one by one.</strong> Commercial factors such as demand and margins may also affect pricing, but at the technical level, this decode-stage inefficiency is part of the underlying reason. The principle of “keep outputs short” is therefore more than a generic savings tip: it directly reduces the most expensive stage.</p>
<p>Is there any way to make this inefficient decode stage even slightly less inefficient? This is where KV cache enters the picture.</p>
<h2 id="how-caching-lowers-the-price"><a class="anchor" href="#how-caching-lowers-the-price">How Caching Lowers the Price</a></h2>
<p>Prompt caching stores static input in a cache once, then lets subsequent calls read it back at a much lower price. According to Anthropic's official documentation, a cache read costs 0.1 times the base input rate—exactly 10%. A cache write costs 1.25 times the base rate with a five-minute TTL (Time To Live, the period for which the cache remains valid), or twice the base rate with a one-hour TTL. You pay a little extra on the first call, then save 90% from the second call onward.</p>
<p>But how can the price fall by 90%? And why is the requirement that “the prefix must match exactly” so strict? Both answers become clear once we look inside the Transformer.</p>
<h3 id="kv-cache"><a class="anchor" href="#kv-cache">KV Cache</a></h3>
<p>When processing input tokens, the model creates corresponding Query/Key/Value (Q/K/V) vectors. Self-attention calculates attention scores using the dot product of Query and Key, then uses those scores to produce a weighted sum of the Values. You can think of it as the calculation that determines how much one token should “refer to” every other token.</p>
<p>This is exactly where the decode stage described above becomes a problem. To generate each new token, the model needs the K/V vectors for all preceding tokens. Recalculating them from scratch every time would repeat the same K/V computation endlessly.</p>
<p><img src="/content/260610/1.webp" alt="How the Transformer KV cache works" width="1536" height="1024" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>KV cache was introduced to eliminate this duplication. It stores previously calculated K/V vectors, so when generating a new token, the model reads and reuses the preceding tokens' K/V values from the cache instead of calculating them again. Within a single sequence, this reduces the cost of generating one additional token from O(n²), which would recalculate the entire sequence at every step, to O(n), which only requires reading the cache. (Strictly speaking, this describes the cost per step, but the central idea is simple: do not recalculate what has already been computed.) This is also why decode is memory-bound: every step must read the KV cache back from memory.</p>
<h3 id="reuse-across-calls"><a class="anchor" href="#reuse-across-calls">Reuse Across Calls</a></h3>
<p>If KV cache was originally designed to accelerate decoding within a single response, prompt caching extends the same memory structure <strong>beyond one call, reusing it across calls</strong>.</p>
<p>The K/V values of a static prefix—such as a system prompt, tool definitions, or code snippets whose beginning remains nearly identical—stay in GPU memory for a five-minute or one-hour TTL. If the next call starts with the same prefix, the model skips that prefix's K/V computation entirely and reads it directly from the cache. More precisely, the input token rate is not reduced by magic: <strong>the GPU work required to compute those tokens disappears altogether.</strong> The 90% price reduction reflects the computation that has been avoided.</p>
<p>Once you understand this mechanism, the strict requirement that “the prefix must match exactly” also makes sense. A cache hit is determined by whether a cumulative hash of the prefix matches. Because self-attention is causal, changing even one earlier token changes the K/V values of every token that follows. Put just one timestamp at the beginning of a prompt, and its value changes on every call, causing the prefix hash to diverge and invalidating the entire cache after it.</p>
<p>Anthropic reads its cache in the hierarchy <code>tools</code> → <code>system</code> → <code>messages</code>. Changing even one tool definition near the beginning therefore invalidates the whole cache that follows. The conclusion is straightforward: <strong>put static content first and dynamic content that changes on every call last if you want the cache to survive.</strong></p>
<p>Caching also has several lesser-known constraints. The minimum cacheable token count differs by model, and even by version within the same family. A prompt shorter than that threshold silently goes uncached even when caching is enabled. (Because these minimum token counts and provider-specific caching policies directly affect cost design, I cover them in detail alongside the pricing tables in the token-saving guide.)</p>
<h2 id="why-the-context-window-sets-a-token-limit"><a class="anchor" href="#why-the-context-window-sets-a-token-limit">Why the Context Window Sets a Token Limit</a></h2>
<p>The final concept no discussion of tokens can omit is the context window: the maximum number of tokens a model can accept at once. Understanding why this limit exists also clarifies the familiar warning that “longer context costs more.”</p>
<p>The key lies in the computational structure of self-attention. Attention involves every token referring to every other token once. With n tokens, there are n × n token pairs, so <strong>the computation and memory required by self-attention grow with the square of the sequence length n.</strong> Double the number of tokens, and the attention cost quadruples. (It is worth clarifying that the entire model is not O(n²); the attention stage is.)</p>
<p><picture><source type="image/webp" srcset="/content/260610/5.330w.webp 330w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260610/5.png" alt="The self-attention matrix in which every token refers to every other token; for n tokens, the number of token pairs grows as n×n" width="330" height="304" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p><a href="https://huggingface.co/docs/transformers/en/llm_tutorial_optimization" target="_blank" rel="noopener noreferrer">Hugging Face's estimates</a> show just how steep this quadratic growth becomes. With standard, unoptimized attention, storing the attention score matrix alone requires only about 50 MB for an input of 1,000 tokens, but about 19 GB for 16,000 tokens and nearly 1 TB for 100,000 tokens.</p>
<p>The KV cache described earlier also consumes memory in proportion to the number of tokens. As the context grows, the K/V values the model must retain accumulate linearly. In the end, the context-window limit is a boundary set by physical constraints: beyond that point, the memory and computation become unmanageable.</p>
<p>This is why carrying a long conversation forward unchanged is not simply a matter of adding more input tokens. Attention costs grow quadratically, and as the token count approaches the limit, the model's ability to handle information accurately also declines. (This loss of accuracy is directly related to context rot, which I discuss in the token-saving guide. It is precisely why keeping context lean benefits both cost and answer quality.)</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>Putting it all together, the way tokens work comes down to a few simple facts. <strong>A token is a vocabulary unit created by BPE from frequently used patterns</strong>, and it enters the model as an embedding vector after being mapped to a token ID. <strong>Input is processed all at once through parallel prefill, while output is more expensive because it must be squeezed out one token at a time</strong>, and the KV cache reduces that inefficiency by reusing K/V values. <strong>Caching lowers the price not through magic, but because the Transformer does not recalculate K/V values it has already computed</strong>, and the demanding requirement for an exact prefix follows directly from this architecture. Finally, longer context costs more because attention cost grows with the square of the token count.</p>
<p>Once you understand this foundation, the economics become much clearer. Strategies such as shortening output, arranging prompts so the cache remains intact, keeping context lean, and switching to a cheaper model when it gives the same answer all rest on these principles. If you have read this far because you wanted to understand the mechanics, I recommend continuing with the token-saving guide to see how these principles translate into real cost reductions.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://en.wikipedia.org/wiki/Byte-pair_encoding" target="_blank" rel="noopener noreferrer">Philip Gage, A New Algorithm for Data Compression (1994)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/1508.07909" target="_blank" rel="noopener noreferrer">Sennrich, Haddow, Birch, Neural Machine Translation of Rare Words with Subword Units (ACL 2016)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://jalammar.github.io/illustrated-transformer/" target="_blank" rel="noopener noreferrer">Jay Alammar, The Illustrated Transformer</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://huggingface.co/learn/nlp-course/en/chapter6/5" target="_blank" rel="noopener noreferrer">HuggingFace, Byte-Pair Encoding tokenization</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://huggingface.co/docs/transformers/en/kv_cache" target="_blank" rel="noopener noreferrer">HuggingFace, Transformers KV Cache strategies</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/openai/tiktoken" target="_blank" rel="noopener noreferrer">OpenAI, tiktoken</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://openai.com/index/hello-gpt-4o/" target="_blank" rel="noopener noreferrer">OpenAI, Hello GPT-4o (Language tokenization)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.artfish.ai/p/all-languages-are-not-created-tokenized" target="_blank" rel="noopener noreferrer">Yennie Jun, All languages are NOT created (tokenized) equal</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices" target="_blank" rel="noopener noreferrer">Databricks, LLM Inference Performance Engineering Best Practices</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.baseten.co/blog/llm-transformer-inference-guide/" target="_blank" rel="noopener noreferrer">Baseten, A guide to LLM inference and performance</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching" target="_blank" rel="noopener noreferrer">Anthropic, Prompt Caching</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Tokens</category>
        </item>
        <item>
            <title><![CDATA[Context Files]]></title>
            <link>https://hooninedev.com/en/260529</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260529</guid>
            <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about the context files that AI coding agents read. This is for developers whose projects have piled up CLAUDE.md, AGENTS.md, SKILL.md, and .cursor/rules side by side, and...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>the context files that AI coding agents read</strong>.</p>
<p>This is for developers whose projects have piled up <code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>SKILL.md</code>, and <code>.cursor/rules</code> side by side, and who are unsure when each is read and what should go in it. By the end, you will know how the files differ, how agents load them, and a rule of thumb backed by an ETH Zurich study: write down only what the agent cannot infer.</p>
<p>As a frontend developer, I use Claude in my day-to-day work. At some point, that meant a <code>CLAUDE.md</code> appeared at the project root, an <code>AGENTS.md</code> created by someone else sat beside it, a <code>.cursorrules</code> file lingered elsewhere, and I even created a <code>.claude/skills/</code> directory after following an article I had come across. (By the time I stopped to take stock, I had about five files containing roughly the same information.)</p>
<h2 id="agents-without-persistent-memory"><a class="anchor" href="#agents-without-persistent-memory">Agents Without Persistent Memory</a></h2>
<p>AI coding agents have a fundamental limitation: <strong>they have no persistent memory</strong>. Every session starts from a blank slate. In the next conversation, they cannot remember a convention you agreed on yesterday or a directory structure you explained an hour ago. Context files are the simplest mechanism for addressing this problem. If a project contains a file that is read automatically whenever a session starts, you no longer need to repeat the same explanation every time.</p>
<p>The problem is that each tool created its own file around the same basic idea. Claude Code reads <code>CLAUDE.md</code>, Cursor reads <code>.cursorrules</code> (now deprecated, with <code>.cursor/rules</code> recommended instead), GitHub Copilot reads <code>.github/copilot-instructions.md</code>, and OpenAI Codex reads <code>AGENTS.md</code>. When a team uses several tools, it can end up copying the same information into four different places.</p>
<h3 id="claudemd"><a class="anchor" href="#claudemd">CLAUDE.md</a></h3>
<p><code>CLAUDE.md</code> is a file that Claude Code reads automatically at the start of a session. According to Anthropic’s official documentation (<code>code.claude.com/docs/en/memory</code>), Claude Code looks for <code>CLAUDE.md</code> at the following three levels.</p>
<ul>
<li><strong>User memory</strong> (<code>~/.claude/CLAUDE.md</code>): global defaults that apply to every project on the machine</li>
<li><strong>Project memory</strong> (<code>CLAUDE.md</code> at the project root): committed to git and shared across the team</li>
<li><strong>Local memory</strong> (<code>CLAUDE.md</code> in a subdirectory): loaded in addition to the others only when working in that directory</li>
</ul>
<p>When all three levels exist, Claude <strong>reads and concatenates all of them</strong>. It does not choose only one according to precedence; instead, more specific instructions are layered on top, much like the CSS cascade. (This is a merge, not an override.) As a result, scattering rules about the same topic across several levels can create conflicts. (Anthropic’s official documentation explicitly states that behavior is not guaranteed when instructions conflict.)</p>
<p>There is one frequently overlooked detail here: Claude <strong>reads every <code>CLAUDE.md</code> it encounters while walking from the current working directory up to the repository root</strong>. If you work inside <code>packages/ui/</code> in a monorepo, both the root <code>CLAUDE.md</code> and <code>packages/ui/CLAUDE.md</code> are loaded. (That is powerful, but it also means the context can quietly expand without you noticing.)</p>
<h3 id="agentsmd"><a class="anchor" href="#agentsmd">AGENTS.md</a></h3>
<p><code>AGENTS.md</code> is a standard created to address the proliferation of tool-specific files described above. In December 2025, OpenAI donated this standard to the Linux Foundation’s <strong>Agentic AI Foundation (AAIF)</strong>. According to the <a href="https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation" target="_blank" rel="noopener noreferrer">Linux Foundation’s announcement</a>, the AAIF was co-founded by Anthropic, Block, and OpenAI, and its founding projects are three: Anthropic’s MCP (the protocol that connects agents to external systems), Block’s goose, and OpenAI’s <code>AGENTS.md</code>. The official site (<code>agents.md</code>) states that <strong>more than 60,000 open-source repositories have adopted the file</strong>.</p>
<p>The list of supported tools makes the picture even clearer. It includes OpenAI Codex, Google Jules, VS Code, GitHub Copilot, Cursor, JetBrains Junie, Aider, Devin, Zed, Factory, Warp, goose, opencode, Amp, RooCode, Gemini CLI, Kilo Code, Phoenix, Semgrep, Ona, Windsurf, and Augment Code, among many others. GitHub Copilot began supporting <code>AGENTS.md</code> natively in August 2025. One interesting detail is that <strong>native <code>AGENTS.md</code> support in Claude Code is still an active feature request</strong>. Claude Code continues to treat <code>CLAUDE.md</code> as its primary file.</p>
<p>You might still wonder whether this supposed standard is actually being adopted. The strongest evidence is <strong>dogfooding</strong>—the organizations behind the standard using it themselves.</p>
<ul>
<li>The canary branch of <strong>Vercel/Next.js</strong> has an <code>AGENTS.md</code> at its root. It is actually a symbolic link to <code>CLAUDE.md</code>, whose contents cover the monorepo structure, one-to-two-second iteration with <code>pnpm --filter=next dev</code>, testing guidance for both Turbopack and Webpack, the <code>pr-status</code> script, and rules for handling environment variables and secrets. The fact that <code>create-next-app</code> now generates both <code>AGENTS.md</code> and <code>CLAUDE.md</code> for new projects reflects the same trend.</li>
<li>The <strong>OpenAI/codex</strong> repository maintains its own <code>AGENTS.md</code>.</li>
</ul>
<p>A conventional strategy is beginning to emerge: use <strong><code>AGENTS.md</code> as the single source of truth</strong>, while keeping <code>CLAUDE.md</code> minimal, with a one-line reference to <code>AGENTS.md</code> plus instructions specific to Claude Code. This eliminates duplication, and because Claude Code reads both files, nothing is lost.</p>
<h3 id="skillmd"><a class="anchor" href="#skillmd">SKILL.md</a></h3>
<p><code>SKILL.md</code> belongs to a different category from the two files above. Whereas <code>CLAUDE.md</code> and <code>AGENTS.md</code> are <strong>persistent instructions that are always present in the context</strong>, a Skill is an <strong>on-demand capability invoked only when needed</strong>.</p>
<p>A Skill is organized as a directory. The directory contains one <code>SKILL.md</code>, scripts executed by the Skill, and any additional Markdown documents. Claude loads that directory only when the current task matches the Skill’s <code>description</code>. This is called <strong>progressive disclosure</strong>, a concept established in the UX field by Jakob Nielsen in 1995: advanced or rarely used functions are deferred to secondary screens so users can focus on one task at a time, reducing cognitive load and errors. In the context of Claude Skills, it refers to the mechanism of bringing a Skill’s body into the context only when needed. The result can be a dramatic reduction in context-window costs.</p>
<p>The frontmatter in <code>SKILL.md</code> includes several distinctive fields.</p>
<ul>
<li><strong><code>description</code></strong>: explains when the Skill is needed and acts as the trigger the model uses to decide whether to invoke it</li>
<li><strong><code>allowed-tools</code></strong>: restricts which tools may be used inside the Skill (for example, <code>"Read, Glob, Grep, Bash(python:*)"</code>)</li>
<li><strong><code>disable-model-invocation: true</code></strong>: prevents the model from invoking the Skill; only the user can trigger it with a slash command. This is used for operations with side effects, such as deployments and commits</li>
<li><strong><code>user-invocable: false</code></strong>: hides the Skill from the user’s slash-command menu and allows only Claude to invoke it autonomously, for use as background knowledge</li>
</ul>
<p>Claude Skills launched simultaneously across Claude.ai, Claude Code, the API, and Agent SDK on October 16, 2025. Then, on December 18, 2025, Anthropic published the Skills specification itself as an open standard (<code>agentskills.io</code>). Simon Willison even called it “<strong>Skills are awesome, maybe a bigger deal than MCP</strong>,” citing the format’s dramatic simplicity compared with MCP and its use of progressive disclosure to address context-window costs.</p>
<p>MCP (Model Context Protocol), which Skills were compared against here, is a standard protocol that connects an agent to external systems such as Slack, GitHub, or a database so it can call them. If context files are about what to tell the agent, MCP is about what to enable the agent to do. How MCP differs from function calling is covered separately in <a href="/260524" target="_blank" rel="noopener noreferrer">MCP and Function Calling</a>.</p>
<p>The tools that cut the cost of an agent finding relevant code (Repomix, Aider, CodeGraph, Serena) cut different costs depending on how deeply they understand the code. That comparison is covered separately in <a href="/260526" target="_blank" rel="noopener noreferrer">Four Tiers of Code Intelligence</a>.</p>
<h3 id="files-used-by-other-tools"><a class="anchor" href="#files-used-by-other-tools">Files Used by Other Tools</a></h3>
<p>Cursor’s <code>.cursorrules</code> has been <strong>deprecated since version 0.43</strong>. The current official recommendation is to use the <code>.cursor/rules/</code> directory and place multiple <code>.mdc</code> files inside it. Each <code>.mdc</code> file has YAML frontmatter.</p>
<ul>
<li><strong><code>description</code></strong>: information the agent uses to judge whether the rule is relevant</li>
<li><strong><code>globs</code></strong>: automatically attaches the rule when a matching file is included in the conversation (auto-attach)</li>
<li><strong><code>alwaysApply</code></strong>: when <code>true</code>, includes the rule in every conversation without exception (<code>globs</code> is ignored in this case)</li>
</ul>
<p>GitHub Copilot has evolved in a similar direction. Repository-wide instructions live in <code>.github/copilot-instructions.md</code>; instructions that require path-specific scope go in <code>.github/instructions/*.instructions.md</code>, with globs specified through the frontmatter key <code>applyTo:</code>. (Copilot code review has officially supported path-scoped instructions since September 2025.)</p>
<p>Tools beyond Cursor and Copilot are converging on similar patterns. The following table summarizes them.</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>File/Directory</th>
<th>Key Characteristics</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Claude Code</strong></td>
<td><code>CLAUDE.md</code> (three levels)</td>
<td>Merged along the directory tree</td>
</tr>
<tr>
<td><strong>Cursor</strong></td>
<td><code>.cursor/rules/*.mdc</code></td>
<td>File-pattern scoping with <code>globs</code></td>
</tr>
<tr>
<td><strong>GitHub Copilot</strong></td>
<td><code>.github/copilot-instructions.md</code> + <code>.github/instructions/*.instructions.md</code></td>
<td>Supports <code>applyTo</code> globs</td>
</tr>
<tr>
<td><strong>Cline</strong></td>
<td><code>.clinerules/</code> directory</td>
<td>Combines all <code>.md</code>/<code>.txt</code> files; conditional activation with <code>paths</code> globs</td>
</tr>
<tr>
<td><strong>Continue.dev</strong></td>
<td><code>.continue/rules/*.md</code></td>
<td><code>name</code>/<code>globs</code>/<code>alwaysApply</code> frontmatter</td>
</tr>
<tr>
<td><strong>Aider</strong></td>
<td><code>CONVENTIONS.md</code> + <code>.aider.conf.yml</code></td>
<td>Included with every request; <strong>200 lines or fewer recommended</strong></td>
</tr>
<tr>
<td><strong>Windsurf</strong></td>
<td><code>.windsurfrules</code> + <code>global_rules.md</code></td>
<td>Two levels: global and project</td>
</tr>
<tr>
<td><strong>Standard</strong></td>
<td><code>AGENTS.md</code> (AAIF)</td>
<td>Adopted by 60,000+ repositories</td>
</tr>
</tbody>
</table>
<p><strong>Aider’s <code>CONVENTIONS.md</code> is particularly interesting</strong>. Because the official documentation says this entire file is included in the context with every request, it explicitly instructs users to <strong>“keep it under 200 lines.”</strong> (In effect, Aider recognized this limitation early and tells users about it directly.)</p>
<h3 id="memorymd"><a class="anchor" href="#memorymd">MEMORY.md</a></h3>
<p>Separate from the files above, another pattern is appearing with increasing frequency: <code>MEMORY.md</code>. It is not an official standard, but an organically developed community convention for <strong>recording decisions and mistakes over time</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="markdown" data-theme="github-dark github-light"><code data-language="markdown" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-dark-font-weight:bold;--shiki-light:#005CC5;--shiki-light-font-weight:bold">## 2026-04-10</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Pages Router에서 App Router로 이전. 신규 라우트는 App Router 컨벤션 사용.</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-dark-font-weight:bold;--shiki-light:#005CC5;--shiki-light-font-weight:bold">## 2026-04-22</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Prisma 쿼리 결과에 optional chaining 쓰지 말 것 — null은 if-check로 명시적 처리.</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(이전에 옵셔널 체이닝으로 null을 흘려보내 프로덕션 이슈 발생.)</span></span></code></pre></figure>
<p>If <code>CLAUDE.md</code> or <code>AGENTS.md</code> records <strong>the rules as they stand today</strong>, <code>MEMORY.md</code> records <strong>the history of why those rules were created</strong>. (The two are complementary, not interchangeable.)</p>
<h3 id="how-agents-read-these-files"><a class="anchor" href="#how-agents-read-these-files">How Agents Read These Files</a></h3>
<p>So far, we have cataloged the files that exist. But one surprisingly common question remains unanswered: <strong>where, exactly, do agents load these files, and how?</strong> Understanding this mechanism makes it easier to see why the ETH Zurich results discussed later—showing that context-file instructions are not followed reliably—emerged.</p>
<p>First, one essential fact: <strong><code>CLAUDE.md</code> is injected as a user message, not as part of the system prompt.</strong> Anthropic’s official documentation states the following.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>CLAUDE.md content is delivered as a user message after the system prompt, not as part of the system prompt itself. Claude reads it and tries to follow it, but there's no guarantee of strict compliance.</p></div><div class="quote-original" lang="en"><p>CLAUDE.md content is delivered as a user message after the system prompt, not as part of the system prompt itself. Claude reads it and tries to follow it, but there's no guarantee of strict compliance.</p></div></blockquote>
<p>In other words, it is contextual guidance rather than an enforced rule. The official guidance recommends using a separate mechanism, such as a <code>PreToolUse</code> hook, when you need to enforce a specific behavior.</p>
<p>The load order progresses from broad → specific. More precisely, it is managed policy (organization-level settings) → the user’s global file (<code>~/.claude/CLAUDE.md</code>) → the project file (<code>./CLAUDE.md</code>) → the local file (<code>./CLAUDE.local.md</code>). Within the same directory, <code>CLAUDE.md</code> comes before <code>CLAUDE.local.md</code>. By taking advantage of the fact that <strong>the nearest instructions are read last</strong>, you can make more specific rules exert a stronger influence, thanks to the LLM’s recency bias.</p>
<p>The <code>@import</code> syntax is especially interesting. If you put <code>@path/to/file</code> anywhere in the body of CLAUDE.md, that file is expanded in place and loaded with it. <strong>The maximum recursion depth is 4 hops</strong>, and relative paths are resolved from the file containing the import statement. That is why the official recommendation is to bridge to <code>@AGENTS.md</code>. If you leave <code>CLAUDE.md</code> almost empty and put only <code>@AGENTS.md</code> in it, Claude Code will naturally read AGENTS.md as well. (Given that Claude Code does not yet support AGENTS.md natively, this is the cleanest workaround.)</p>
<p>The token implications also deserve attention. CLAUDE.md has no explicit token limit, so <strong>the entire file is loaded if it exists</strong>. The official recommendation, however, is to keep <strong>each file under 200 lines</strong>. Beyond 200 lines, it is said to “consume more context and may reduce adherence.” Interestingly, in Claude 4.x, <strong>merely enabling tool use automatically adds 346 tokens via a special system prompt</strong> (with <code>tool_choice: auto</code>). Context leaks away in ways that are easy to miss.</p>
<p>Cursor takes a different approach. Rules in <code>.cursor/rules/*.mdc</code> operate in four modes.</p>
<ul>
<li><strong>Always Apply</strong>: included in every chat without exception; ignores globs/description</li>
<li><strong>Apply Intelligently</strong> (Agent Requested): the agent reads <code>description</code>, determines relevance, and pulls in the rule</li>
<li><strong>Apply to Specific Files</strong> (Auto Attached): activated when a file matching the glob pattern enters the context</li>
<li><strong>Apply Manually</strong>: explicitly invoked by the user with <code>@rule-name</code></li>
</ul>
<p>Other tools differ again. OpenAI Codex walks from the git repository root toward cwd, collects every <code>AGENTS.md</code>, and injects them <strong>immediately before the user prompt</strong>. GitHub Copilot inserts <code>.github/copilot-instructions.md</code> at a middle priority within the context window: “after edit context and explicit references, but before loosely related open files.” Because the load timing, precedence, and merge rules vary by tool even for the same <code>AGENTS.md</code> file, <strong>there is no guarantee that three tools will interpret it in exactly the same way.</strong></p>
<p>That leaves a more fundamental question: <strong>why does a model follow only some instructions that are present in its context?</strong> Simply saying “because the instructions are long” is not enough. The phenomenon is rooted in structural limitations of LLMs.</p>
<h3 id="hallucination-and-context-forgetting"><a class="anchor" href="#hallucination-and-context-forgetting">Hallucination and Context Forgetting</a></h3>
<p>If you have ever seen an AI agent confuse the conversational context or forget something that was clearly stated earlier, that is a form of <strong>hallucination</strong>. People usually think of hallucination first as “making up facts that do not exist,” but the academic literature divides it into three categories. A 2023 survey by Yue Zhang and colleagues (“Siren’s Song in the AI Ocean”) classifies them as <strong>input-conflicting</strong> (generating something inconsistent with what the user explicitly provided), <strong>context-conflicting</strong> (contradicting something the model previously generated), and <strong>fact-conflicting</strong> (disagreeing with world knowledge). Ignoring instructions in a context file belongs to the <strong>first category</strong>, not the third. The model processes the input while treating part of its information as though it were not there.</p>
<p>The deeper problem is that hallucination <strong>cannot be eliminated in principle</strong>. A research team at the National University of Singapore proved this mathematically using learning theory. No LLM can learn every computable function; therefore, as long as it is used as a general-purpose problem solver, it must hallucinate at some point.</p>
<p>Position effects matter as well. Stanford researchers demonstrated experimentally that models reference relevant information most effectively when it appears <strong>at the beginning or end of the context window</strong>, while performance drops sharply when that information is <strong>buried in the middle</strong>. This maps directly onto context files. <code>CLAUDE.md</code> is inserted somewhere in the middle by the load order, and as a conversation grows longer, its instructions are pushed further into the “middle” of the context. This also connects to the other side of the recency bias mentioned earlier: within the primacy-recency effect, <strong>the middle is the weakest region</strong>.</p>
<p>Taken together, these phenomena form a coherent picture. A context file is merely <strong>additional text inserted outside the system prompt before the LLM’s first user turn</strong>. It is not a mechanism that forces the model’s decisions; it is simply another block of tokens dropped into the context window. The longer the file and the longer the conversation, the farther its instructions drift toward the “middle,” and the less reliably they are referenced. The ETH Zurich results quantitatively confirm this structural limitation.</p>
<h3 id="the-eth-zurich-study"><a class="anchor" href="#the-eth-zurich-study">The ETH Zurich Study</a></h3>
<p>Many people may have thought, “Then I should put as much as possible into these files.” A recent study directly challenges that intuition: the ETH Zurich research referenced throughout the preceding section.</p>
<p>The paper, “Evaluating AGENTS.md: Are Repository-Level Context Files Helpful for Coding Agents?”, was published by an ETH Zurich research team in February 2026. The researchers evaluated four agents—Claude Code (Sonnet-4.5), Codex (GPT-5.2 / GPT-5.1 mini), and Qwen Code—on a benchmark of 138 real-world Python software-engineering tasks (AGENTBENCH) and on SWE-bench Lite. The results were unexpected.</p>
<ul>
<li><strong>LLM-generated context files</strong> actually <strong>reduced task success rates</strong> by about 0.5% on SWE-bench Lite and 2% on AGENTBENCH</li>
<li>Even <strong>human-authored files</strong> produced only a modest average improvement of about 4%</li>
<li>Adding a context file <strong>increased inference costs by more than 20% per instance</strong></li>
<li>Context files had an even smaller effect on the stronger model (GPT-5.2), because more capable models already have sufficient parametric knowledge and additional context can become noise</li>
</ul>
<p>There was one exception: <strong>specifying a nonstandard tool</strong>. When the Python package manager <code>uv</code> was named in the context, for example, the agent’s use of <code>uv</code> rose from 0.01 times per instance to 1.6 times—an increase of <strong>roughly 160×</strong>.</p>
<p>Aider’s “200-line recommendation”—keep the file short because it enters the context every time—is practical guidance, while the ETH Zurich study quantitatively demonstrates that long context files reduce performance statistically. I see the following practical implications in the research.</p>
<ul>
<li><strong>A huge, automatically generated context file can do more harm than good</strong>. If you cram coding standards, architecture, and workflows into a 300-line <code>CLAUDE.md</code>, the agent will follow some of them and ignore the rest. That inconsistency can produce worse results than having no context at all.</li>
<li><strong>What absolutely belongs in the file is information that cannot be inferred</strong>. This includes nonstandard tools, project-specific conventions, and past failure cases. The model already knows general coding best practices.</li>
<li>Use AGENTS.md as the single source, keep CLAUDE.md limited to brief tool-specific instructions, and move detailed workflows into Skills.</li>
</ul>
<h2 id="so-what-does-this-mean"><a class="anchor" href="#so-what-does-this-mean">So, What Does This Mean?</a></h2>
<p>Context files differ from tool to tool in name, location, and when they are read. This article alone covered CLAUDE.md, AGENTS.md, SKILL.md, Cursor rules, copilot-instructions.md, and MEMORY.md, and which tool supports which file keeps changing. Memorizing a list of files goes stale quickly.</p>
<p>That is why my goal here was not to recommend a particular file format, but to develop <strong>an eye for how agents actually read these files</strong>. Once you understand why CLAUDE.md is injected as a user message, why different tools read the same AGENTS.md differently, and why instructions weaken in the middle of the context, a new context file format becomes much easier to read: “This is when it gets loaded, and this is how strongly it acts.”</p>
<p>What remains, ultimately, is one intuition from the ETH Zurich study: <strong>the model already knows a great deal</strong>. Stuffing a context file with everything you can think of does not make the agent follow it more faithfully. It is better to keep only what the model is unlikely to know—project-specific conventions, nonstandard tools, and past mistakes—and remove the rest. Writing a long context file and writing a good one are different problems.</p>
<p>Rather than expanding CLAUDE.md to hundreds of lines right away, I encourage readers to dig, at least once, into when, where, and how strongly the tools they already use read that file. I believe that understanding provides a stable foundation, however file formats change.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://code.claude.com/docs/en/memory" target="_blank" rel="noopener noreferrer">Claude Code Memory, Anthropic</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" target="_blank" rel="noopener noreferrer">Anthropic Tool Use Overview</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://cursor.com/docs/context/rules" target="_blank" rel="noopener noreferrer">Cursor Rules Documentation</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2602.11988" target="_blank" rel="noopener noreferrer">ETH Zurich, "Evaluating AGENTS.md" (2602.11988)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2309.01219" target="_blank" rel="noopener noreferrer">Yue Zhang et al., "Siren's Song" (2309.01219)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2401.11817" target="_blank" rel="noopener noreferrer">Ziwei Xu et al., "Hallucination is Inevitable" (2401.11817)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://arxiv.org/abs/2307.03172" target="_blank" rel="noopener noreferrer">Nelson F. Liu et al., "Lost in the Middle" (2307.03172)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://simonwillison.net/2025/Oct/16/claude-skills/" target="_blank" rel="noopener noreferrer">Simon Willison, "Claude Skills are awesome"</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Developer-Tools</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[Four Tiers of Code Intelligence]]></title>
            <link>https://hooninedev.com/en/260526</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260526</guid>
            <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about how the tools that cut an AI coding agent's cost of finding relevant code differ from one another. This is for developers who have watched an agent burn tokens by re...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>how the tools that cut an AI coding agent's cost of finding relevant code differ from one another</strong>.</p>
<p>This is for developers who have watched an agent burn tokens by repeating grep and file reads in a large codebase, and who are wondering which tool to add, such as Repomix, CodeGraph, or Serena. These tools split into four tiers by how deeply they understand code (context packing, tree-sitter repository maps, knowledge graphs, and LSP), and each tier cuts the search cost in a different place. For the knowledge graph I include its maker's benchmark; for the other three tiers, measurements taken on this blog's own repository.</p>
<p>Ever since I saw <code>codegraph</code> on GitHub Trending and installed it myself, I have wondered, every time I come across a new tool, how exactly it saves tokens. Many of the tools in this post, too, I first came across on GitHub Trending, which I usually browse weekly, filtered to TypeScript and Python.</p>
<h2 id="code-intelligence-tools"><a class="anchor" href="#code-intelligence-tools">Code Intelligence Tools</a></h2>
<p>Before an agent changes any code, it first has to find where the relevant code lives. It greps, reads files, filters, and greps again. In the CodeGraph benchmark we will look at later, the side answering without the tool used up to 43 tool calls for a single question. Code intelligence tools are attempts to cut this search cost.</p>
<p>"Cost" does not point to one thing, though. Tokens the model processed, the number of tool calls, and tokens still sitting in the context window after the work is done all move separately. For each tier, this post looks at which of the three it cuts.</p>
<p>I divide these attempts into the four tiers below, based on how deeply each tool understands code. There is no established industry classification for this; the grouping is my own. Every measured value in this post was taken on 2026-10-08 at commit <code>36e5cfa</code> of this repository, and tokens were counted with tiktoken's <code>o200k_base</code>. The values differ from Claude's tokenizer, so use them for comparison rather than as absolute numbers.</p>
<h3 id="context-packing"><a class="anchor" href="#context-packing">Context Packing</a></h3>
<p>The simplest solution begins with the idea: "<strong>Put everything into one context window.</strong>" It builds no graph and performs no indexing. It simply serializes the entire repository into a block of text and hands the whole thing to the model.</p>
<p>The representative tool is <strong>Repomix</strong>. Its default output format is XML, and its README links to Anthropic's documentation on XML tags. It ships a CLI, a web app, a browser extension, and an MCP server.</p>
<p><strong>GitIngest</strong>'s strength is that it needs no installation. Change <code>github.com</code> to <code>gitingest.com</code> in a GitHub URL, and the whole repository becomes a single text page. <strong>code2prompt</strong> (made by Mufeed VH), a Rust CLI, lets you change the output format with templates.</p>
<p><strong>rtk</strong> (<code>rtk-ai/rtk</code>) takes a slightly different direction. Where the tools above pack the whole repository at once, rtk compresses the output of the CLI commands an agent runs. It is a single Rust binary, registered in the hooks of several agents including Claude Code, Cursor, Copilot, Gemini CLI, and Codex, so when the agent calls <code>git status</code>, it runs <code>rtk git status</code> instead. It applies filtering, grouping, truncation, and deduplication to more than 100 commands. rtk's hook targets only Bash tool calls. Claude Code's <code>Read</code>, <code>Grep</code>, and <code>Glob</code> pass straight through.</p>
<p>The size of the reduction needs careful reading. The <a href="https://github.com/rtk-ai/rtk/blob/8533612180c60efbcb5827c7db4e910aba096705/README.md#L66-L70" target="_blank" rel="noopener noreferrer">rtk README</a> says it cuts Bash output by up to 90%, then immediately adds that this does not mean cutting your bill by 90%. From the model's side, command output is part of the input tokens, and input tokens are part of the bill, so the reduction dilutes at each step. The figure the <a href="https://www.rtk-ai.app/" target="_blank" rel="noopener noreferrer">official site</a> puts forward is 56% on average, for the commands rtk rewrites. Where the tools above shrink the text going in, rtk shrinks the text coming back as tool call results.</p>
<p>The limit of this tier is that <strong>large repositories hit the token ceiling</strong>. Packing just the 109 files in this blog's <code>src/</code> comes to 94,596 tokens. That still fits in the window, but a repository with thousands of files would hit the limit right away. Repomix answers this with <code>--compress</code>. According to the <a href="https://github.com/yamadashy/repomix/blob/8d6429121e98ed178e4d3a975c2bdbbecc958c4a/README.md#L797-L831" target="_blank" rel="noopener noreferrer">README</a>, it uses Tree-sitter to keep function and class signatures and drop the implementation bodies.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># repomix 1.18.1, 이 리포 커밋 36e5cfa, 2026-10-08</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -y</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> repomix@1.18.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> out.xml</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">              # Total Tokens: 94,596 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -y</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> repomix@1.18.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --compress</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> out.xml</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # Total Tokens: 30,320 tokens</span></span></code></pre></figure>
<p>That is a 68% reduction. Comments stay, so <code>visit-counter.ts</code>, which has long JSDoc, only went from 1,642 to about 1,220 tokens, a 26% cut. The compressed output keeps import statements and signatures, but the calls inside function bodies disappear. You can see in the text which file imports what, but you cannot ask who calls whom. So the boundary between this tier and the next lies less in whether a tool sees syntax than in whether it can be asked about relationships.</p>
<h3 id="tree-sitter-repository-maps"><a class="anchor" href="#tree-sitter-repository-maps">tree-sitter Repository Maps</a></h3>
<p>The next tier uses <strong>tree-sitter</strong> to analyze code structure without running a separate index server.</p>
<p>What tree-sitter, the tool this tier relies on, builds is a <strong>CST (Concrete Syntax Tree)</strong>, a tree that represents the structure of source code. The <a href="https://tree-sitter.github.io/tree-sitter/" target="_blank" rel="noopener noreferrer">official tree-sitter documentation</a> also says it builds a concrete syntax tree. That tree keeps even parentheses and punctuation as nodes, and it is the tree the tools below work with.</p>
<p><strong>tree-sitter</strong> is an open-source parser generator and incremental parsing library. <a href="https://docs.github.com/en/repositories/working-with-files/using-files/navigating-code-on-github" target="_blank" rel="noopener noreferrer">GitHub's code navigation</a> uses tree-sitter. Because it reparses only the edited part, changing one line in an editor does not reparse the whole file; it fixes only the changed part of the tree. That advantage belongs to editors, where edits keep happening. Aider, below, keeps a cache keyed on file modification time so it does not reparse files that have not changed.</p>
<p><strong>Aider</strong>, an AI pair programming tool used in the terminal, is the representative example of this approach. Aider uses tree-sitter to extract definitions and references of functions, classes, and methods from each file. It then builds a graph with files as nodes. When file A references an identifier defined in file B, an edge runs from A to B.</p>
<p>To pick the important files in this graph, Aider uses PageRank. PageRank is an algorithm that gives a node a higher score the more links it receives, and the heavier they are. Aider uses a variant called personalized PageRank, which tilts the scores toward chosen nodes. It then fits the definitions and signatures of the top-ranked files into the token budget.</p>
<p>What goes into the budget changes with the current conversation. In Aider's <a href="https://github.com/Aider-AI/aider/blob/5dc9490bb35f9729ef2c95d00a19ccd30c26339c/aider/repomap.py#L487-L525" target="_blank" rel="noopener noreferrer"><code>repomap.py</code></a>, an edge weight is the square root of the reference count times a multiplier. Identifiers mentioned in the conversation get 10x, and camelCase, snake_case, or kebab-case identifiers of 8 or more characters also get 10x. Identifiers starting with <code>_</code> and identifiers defined in more than 5 files each get 0.1x, and edges going out of files currently added to the chat get 50x. Files in the chat, files mentioned in the conversation, and files whose path or file name matches an identifier in the conversation also receive PageRank personalization scores.</p>
<p>The budget is not fixed either. <a href="https://aider.chat/docs/repomap.html" target="_blank" rel="noopener noreferrer">Aider's documentation</a> gives the <code>--map-tokens</code> default as 1k, but the <a href="https://github.com/Aider-AI/aider/blob/5dc9490bb35f9729ef2c95d00a19ccd30c26339c/aider/models.py#L782-L789" target="_blank" rel="noopener noreferrer">code</a> clamps one eighth of the model's input limit to between 1,024 and 4,096. And when no files are in the chat, it grows up to <code>--map-multiplier-no-files</code> (default 2) times. I checked this with this repository's <code>src/</code>. The 0.86.1 release I ran and the commit linked above have the same ranking code. The printed maps were counted with o200k_base, including the three lines of guidance text Aider adds.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># aider-chat 0.86.1, 리포 루트에서 시작한다, 2026-10-08</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mkdir</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">cp</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -R</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">cd</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> /tmp/aidermap</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -q</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> add</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -A</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x26;&#x26; </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> commit</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -qm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 키는 더미 값이다. --show-repo-map 은 map 만 출력하고 LLM 을 부르지 않는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> OPENAI_API_KEY</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">dummy</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() { </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">uvx</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --python</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 3.12</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> aider-chat==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.86.1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> aider</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --no-check-update</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --analytics-disable</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --no-gitignore</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --model</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> gpt-4o</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">$@</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">                                              # Repo-map: using 4096 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --map-tokens</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1024</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">                            # Repo-map: using 1024 tokens</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">aider_map</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --map-tokens</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1024</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --show-repo-map</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> src/lib/filter-posts.ts</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    # 이 파일을 채팅에 올린 상태</span></span></code></pre></figure>
<table>
<thead>
<tr>
<th>Condition</th>
<th>Budget Aider chose</th>
<th>Map printed</th>
<th>Files included</th>
</tr>
</thead>
<tbody>
<tr>
<td>Defaults, no file in chat</td>
<td>4,096</td>
<td>7,370 tokens</td>
<td>61</td>
</tr>
<tr>
<td><code>--map-tokens 1024</code>, no file in chat</td>
<td>1,024</td>
<td>2,019 tokens</td>
<td>27</td>
</tr>
<tr>
<td><code>--map-tokens 1024</code>, <code>filter-posts.ts</code> added to chat</td>
<td>1,024</td>
<td>983 tokens</td>
<td>12</td>
</tr>
</tbody>
</table>
<p>gpt-4o has a 128k input limit, so one eighth was clamped to 4,096, and with an empty chat the map came out within twice that. Once one file was added to the chat, the map shrank to fit the budget; only 10 of the earlier 27 files remained, and <code>PostList.tsx</code> and <code>SearchModal.tsx</code> came in new. A repo map is not a fixed summary of the repository but an excerpt fitted to the conversation at that moment.</p>
<p>There is one more tool worth noting in this tier: <strong>ast-grep</strong> (<code>ast-grep/ast-grep</code>). It is a tree-sitter-based CLI for structural search and rewrite, and it matches syntax tree nodes rather than text. For example, the pattern <code>console.log($A)</code> catches every call to <code>console.log</code> with a single argument, regardless of how line breaks or whitespace look. What it catches is the same syntactic structure. It does not know the type of <code>$A</code> or where the name <code>console</code> comes from. There is also an <code>ast-grep-mcp</code> server, so you can make an agent use structural search instead of text grep.</p>
<h3 id="knowledge-graph"><a class="anchor" href="#knowledge-graph">Knowledge Graph</a></h3>
<p>The third tier goes one step further. It <strong>parses the entire codebase in advance, builds a knowledge graph, and stores it on disk</strong>; the agent then sends queries to that stored graph. The most talked-about example is a tool called <strong>CodeGraph</strong>.</p>
<p>The structure the <a href="https://github.com/colbymchenry/codegraph/blob/b635dd467f0578926a9c01a37b9d28d2b26689f1/README.md" target="_blank" rel="noopener noreferrer">CodeGraph README</a> describes is simple. It parses code with tree-sitter to extract symbols, edges, and file information, and stores them in a local SQLite database. Name search goes through SQLite's FTS5 index. The agent asks this graph through MCP. The steps the README describes involve no LLM. So I take this extraction to be deterministic, meaning the same code always yields the same result.</p>
<p>If an LLM summarizes code to build the graph, results can vary even for the same code, and hallucinations can creep in. Parsing the syntax tree directly, by contrast, extracts symbol relationships only by the rules of the language grammar, leaving no room for that kind of interpretation.</p>
<p><strong>FTS5 (SQLite Full-Text Search 5)</strong>, which appears here, is a full-text search extension provided as an SQLite virtual table. According to the <a href="https://www.sqlite.org/fts5.html" target="_blank" rel="noopener noreferrer">SQLite documentation</a>, it has been included in the amalgamation since 3.9.0 (2015-10-14); you create a table with <code>CREATE VIRTUAL TABLE ... USING fts5(...)</code> and query it with the <code>MATCH</code> operator. You can keep a full-text index in a single SQLite file without running a separate search engine such as Elasticsearch.</p>
<p>Deterministic is not the same as complete, though. The same README lists route recognition for frameworks that lean on convention and reflection as ranging from 74.1% for Django to 83.9% for ASP.NET, and calls this the honest static-analysis ceiling. The same code gives the same result, but that result can be missing edges.</p>
<p>The benchmark was measured by CodeGraph itself. The same README's 2026-08-05 re-measurement ran Claude Opus 4.8 headless and asked one architecture question each on 7 open-source repositories. The side with CodeGraph MCP enabled cut average cost by 44%, processed tokens by 62%, and tool calls by 88%. This re-measurement blocked both sides from calling the <code>codegraph</code> CLI through Bash. In a measurement setup without that block, the side without the tool found and used the CLI in 26 of 28 runs, and the README states that earlier published figures were produced without the block.</p>
<p>The size of the savings did not follow repository size. On questions where the side without the tool used 28 to 43 tool calls, cost fell by 57 to 78%, and on Gin, which finished in 7, it was about even. VS Code, with about 11k files, came in at 71%, and Excalidraw, with about 640, at 78%. The README attributes this to how much searching each question required. With only one question per repository, I read only the direction from it.</p>
<p>The README also records a number pointing the other way. Processed tokens go down, but at the end of a multi-turn session, the retrieval results still sitting in the context window are about 80% larger on the CodeGraph side across the 7 repositories. The gap varies by repository; on VS Code alone it is 67k versus 18k tokens, about 3.7 times. It returns dense source text in one go, and that text stays in the window. CodeGraph cut tool calls and processed tokens, at the price of leaving more in the window.</p>
<p><strong>Cursor</strong> took a different road and then changed course. The indexing described in the <a href="https://cursor.com/blog/secure-codebase-indexing" target="_blank" rel="noopener noreferrer">January 2026 Cursor blog post</a> was not a syntax graph but <strong>semantic search based on vector embeddings</strong>. It split files into chunks locally, synced with the server via Merkle tree hashes, and turned the chunks into embeddings for semantic search. In July 2026, a Cursor Community Support Engineer replied on the <a href="https://forum.cursor.com/t/what-do-you-think-about-cursor-removing-the-codebase-indexing-settings/165899" target="_blank" rel="noopener noreferrer">forum</a>: "Semantic/embeddings indexing is being turned down in favor of grep-based retrieval". In the same thread, another staff member wrote that as models got good at using grep, the older semantic search path was no longer helping in a meaningful way. The <a href="https://cursor.com/docs/context/codebase-indexing" target="_blank" rel="noopener noreferrer">Cursor documentation</a> now says Instant Grep builds and queries its index on your machine and does not store embeddings of your codebase for search.</p>
<h3 id="lsp"><a class="anchor" href="#lsp">LSP</a></h3>
<p>The last tier <strong>relies directly on a language server</strong>. If tree-sitter knows "that a symbol exists," LSP knows "what that symbol is."</p>
<p><strong><a href="https://microsoft.github.io/language-server-protocol/" target="_blank" rel="noopener noreferrer">LSP (Language Server Protocol)</a></strong> is an open, JSON-RPC-based protocol that standardizes communication between editors and language analysis tools (code completion, go to definition, find references, refactoring, and so on). In 2016, <a href="https://www.redhat.com/en/about/press-releases/red-hat-codenvy-and-microsoft-collaborate-language-server-protocol" target="_blank" rel="noopener noreferrer">Microsoft, Red Hat, and Codenvy announced a collaboration on it</a>. The core idea is "don't reimplement a language analyzer for every editor; keep one server per language and have every editor query it." rust-analyzer and Python's pyright are LSP servers, while TypeScript uses typescript-language-server, which wraps <code>tsserver</code> (which speaks its own protocol) in LSP.</p>
<p><strong>Serena</strong> (<code>oraios/serena</code>) is an MCP server in this tier. As of 2026-10-08 it has 30,093 stars, and the repository was created in March 2025. Serena's core idea fits in one line: <strong>show the agent code as symbols.</strong> Its core tools include <code>find_symbol</code>, <code>find_referencing_symbols</code>, and <code>get_symbols_overview</code>. You can choose one of two backends. The default is a language server implementing LSP (free/open source); the other option is a paid plugin that uses JetBrains IDE code analysis (with a free trial).</p>
<p>Measuring on this repository shows where the difference comes from. I looked for usages of <code>isHiddenPost</code> (<code>src/lib/filter-posts.ts:12</code>), which filters out private posts, in two ways. The text side is grep.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 커밋 36e5cfa, 2026-10-08. 이 글도 같은 이름을 담고 있어 content/ 는 뺐다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> grep</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -n</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> isHiddenPost</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ':!content'</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # 16줄, 파일 9개</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">git</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> grep</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -n</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -C1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> isHiddenPost</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ':!content'</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">   # 같은 16곳을 앞뒤 1줄과 함께</span></span></code></pre></figure>
<p>On the LSP side, Serena's <a href="https://github.com/oraios/serena/blob/3b99f8b024dafd58c962ea6e74f37c8a730ef532/src/serena/tools/symbol_tools.py#L169-L172" target="_blank" rel="noopener noreferrer"><code>find_referencing_symbols</code></a> was called. LSP's find references asks by position (file, line, and column), not by name, so this tool also takes both <code>name_path</code>, the symbol name, and <code>relative_path</code>, the file holding the definition. Rather than running Serena as an MCP server, the tool was called directly from Python on the same commit.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="python" data-theme="github-dark github-light"><code data-language="python" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># serena 3b99f8b, 커밋 36e5cfa, 2026-10-08. 리포 루트에서 실행한다</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># uvx --python 3.12 --from git+https://github.com/oraios/serena@3b99f8b024dafd58c962ea6e74f37c8a730ef532 python refs.py</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.agent </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaAgent</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.config.serena_config </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaConfig</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> serena.tools.symbol_tools </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FindReferencingSymbolsTool</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">agent </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SerenaAgent(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">project</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">serena_config</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SerenaConfig().with_headless_mode_overrides())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">agent.execute_task(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">lambda</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">None</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 언어 서버가 뜰 때까지 기다린다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">tool </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> agent.get_tool(FindReferencingSymbolsTool)</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">print</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(agent.execute_task(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">lambda</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: tool.apply(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name_path</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"isHiddenPost"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">relative_path</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"src/lib/filter-posts.ts"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)))</span></span></code></pre></figure>
<p>The output is a single line of JSON. Here is just the first entry. Line numbers count from 0.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>{"src/lib/filter-posts.ts": {"Function": [{"name_path": "filterPublishedPosts", "body_location": {"start_line": 22, "end_line": 24}, "content_around_reference": "...  22:export function filterPublishedPosts(posts: Post[]): Post[] {\n  >  23:  return posts.filter(post => !isHiddenPost(post))\n...  24:}"}]}, ...</span></span></code></pre></figure>
<p>Serena returned 10 references. It leaves out the definition at <code>filter-posts.ts:12</code>, and for the import lines in three files it returns the file itself as the symbol (<code>File</code>). The 11 code locations grep caught are these 10 plus the one definition line. This name is unique in the repository, so grep neither missed nor added any code location. The 5 extra places grep caught were sentences in docs explaining this function: the root <code>CLAUDE.md</code>, the <code>.claude/commands/</code> files <code>audit.md</code> and <code>write-post.md</code>, and, in <code>docs/research/snapshot-20260816/</code>, the leftover copies of <code>CLAUDE.md</code> and <code>write-post.md</code>.</p>
<p>Serena returns each reference with <a href="https://github.com/oraios/serena/blob/3b99f8b024dafd58c962ea6e74f37c8a730ef532/src/serena/repl/api/lsp_api.py#L367-L369" target="_blank" rel="noopener noreferrer">one line before and after</a>. So grep was run two ways, <code>git grep -n</code>, which returns only line numbers, and <code>git grep -n -C1</code>, which also returns one line before and after, and all three outputs were counted in full with <code>o200k_base</code>.</p>
<table>
<thead>
<tr>
<th>Output</th>
<th>Locations</th>
<th>tokens</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>git grep -n</code></td>
<td>16 (code 11, docs 5)</td>
<td>703</td>
</tr>
<tr>
<td><code>git grep -n -C1</code></td>
<td>16 (code 11, docs 5)</td>
<td>1,575 (code 733, docs 841)</td>
</tr>
<tr>
<td>Serena <code>find_referencing_symbols</code></td>
<td>10</td>
<td>892</td>
</tr>
</tbody>
</table>
<p>Compared with a grep that returns one line before and after, Serena uses 683 fewer tokens. The difference comes from not returning the 5 doc locations. But Serena attaches the symbol name and location information to each reference as JSON, so on the code side alone it actually uses 159 more tokens. And Serena's 892 tokens are more than the 703 of <code>git grep -n</code>, which returns only line numbers. What the LSP tier saves in this repository is not the search result but the next step, where the agent reads surrounding lines or files after seeing the grep result.</p>
<p>In that next step, if the agent read all 9 files grep hit in full, it would read 47,034 tokens: 3,716 from the 4 code files and 43,318 from the 5 doc files. That is an upper bound. In a real session, <code>CLAUDE.md</code> is likely already in context and would not be read again. With a common name like <code>isLocale</code>, results could differ even in code locations.</p>
<h2 id="wrapping-up"><a class="anchor" href="#wrapping-up">Wrapping Up</a></h2>
<p>To sum up, the four tiers split by how deeply they understand code, and they cut different costs. Context packing hands over code as text, and even with <code>--compress</code> it keeps only syntax, cutting the tokens going in. A tree-sitter repository map knows that a symbol exists, puts a budget cap on the tokens going in, and lets the conversation at that moment decide what fills it. A knowledge graph stores relationships in advance and cuts tool calls and processed tokens, but by the vendor's measurement, what stays in the window actually grew. LSP knows what a symbol is; in this repository it did not return the same name found in docs, but attached metadata to every result instead. Where it saves is not the search result but the reading that follows.</p>
<p>So when I pick a tool, I look first at which cost is the problem right now, rather than at how deep the tier goes. If the window is small and sessions are long, I look at what stays behind; if round trips are slow, at the number of tool calls; if docs and code share names in the repository, at how many files I end up reading after a grep result. I read Cursor stripping out embedding-based semantic search and moving to a local text index (Instant Grep) as a sign that an expensive index is not always better than the model's own ability to search.</p>
<p>If these tools cut the cost of an agent finding code, how much of the project rules an agent should know from the start to write, and in which file, is a separate problem. I cover that in <a href="/260529" target="_blank" rel="noopener noreferrer">Context Files</a>.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/ast-grep/ast-grep" target="_blank" rel="noopener noreferrer">ast-grep/ast-grep</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/ast-grep/ast-grep-mcp" target="_blank" rel="noopener noreferrer">ast-grep/ast-grep-mcp</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Developer-Tools</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[MCP and Function Calling]]></title>
            <link>https://hooninedev.com/en/260524</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260524</guid>
            <pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about how MCP (Model Context Protocol) differs from function calling. This is for developers who use MCP servers with Claude Code or Cursor but have found it hard to expla...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>how MCP (Model Context Protocol) differs from function calling</strong>.</p>
<p>This is for developers who use MCP servers with Claude Code or Cursor but have found it hard to explain where MCP parts ways with the function calling in LLM APIs. The short answer is that MCP does not replace function calling. The host application converts the tool list it receives from an MCP server into the <code>tools</code> parameter of function calling, and passes the call the model chooses back to the MCP server. By the end, you will know which part of the protocol each of the four differences MCP adds on top comes from, which differences survive the 2026-07-28 revision, and what attack surface that structure opens.</p>
<p>I work as a frontend developer and use Claude every day, yet each time I added another MCP server, I was never quite sure how these tools actually entered the model's field of view.</p>
<h2 id="mcp-model-context-protocol"><a class="anchor" href="#mcp-model-context-protocol">MCP (Model Context Protocol)</a></h2>
<p>MCP (Model Context Protocol) solves the question of “<strong>what should we enable the agent to do?</strong>”</p>
<p>Put more concretely, an AI agent needs to be able to call the Slack API to send a message to Slack. It needs to call the GitHub API to create a GitHub issue. It needs to handle a database connection to query Postgres. MCP <strong>unifies integrations with all these external systems under a single standard protocol</strong>. (The point is that clients and servers connect through the same specification.)</p>
<p>MCP is an open standard first released by Anthropic on <strong>November 25, 2024</strong>. Then, on <strong>December 9, 2025</strong>, Anthropic donated MCP to the Linux Foundation’s <a href="https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation" target="_blank" rel="noopener noreferrer">Agentic AI Foundation (AAIF)</a>. The AAIF was co-founded by Anthropic, Block, and OpenAI.</p>
<p>MCP is a protocol built on JSON-RPC. <a href="https://www.jsonrpc.org/specification" target="_blank" rel="noopener noreferrer">JSON-RPC 2.0</a> is a stateless, lightweight RPC (Remote Procedure Call) protocol that uses JSON as its wire format. It is transport-independent and can run over HTTP, TCP, or standard input/output. This post describes the 2025-11-25 revision, in which MCP is a stateful protocol that sets up a session for each connection. What changed in the revision after that is covered once we have walked through the call flow.</p>
<h3 id="six-primitives"><a class="anchor" href="#six-primitives">Six Primitives</a></h3>
<p>The overview of the 2025-11-25 specification lists three features that servers offer and three features that clients offer. This post calls these six primitives. Here, a primitive has nothing to do with JavaScript's primitive types (such as string or number); it refers to a basic type of interaction defined by the protocol.</p>
<h4 id="server-side-primitives"><a class="anchor" href="#server-side-primitives">Server-side primitives</a></h4>
<ul>
<li><strong>Tool</strong> (model-controlled): an action the model autonomously decides whether to invoke. Such actions may have side effects</li>
<li><strong>Resource</strong> (application-controlled): data identified by a URI. The specification only has <code>resources/read</code> for reading its contents and no method for writing. How those resources go into the context is decided by the host application</li>
<li><strong>Prompt</strong> (user-controlled): a reusable template explicitly triggered by the user, for example through a slash command</li>
</ul>
<h4 id="client-side-primitives"><a class="anchor" href="#client-side-primitives">Client-side primitives</a></h4>
<ul>
<li><strong>Sampling</strong>: a mechanism that allows the server to request a completion from the client’s LLM, making the client-server architecture bidirectional. It lets a server that needs generated text while running a tool borrow the model the client uses, without an API key of its own. It was deprecated, that is, scheduled for removal, in the 2026-07-28 revision.</li>
<li><strong>Roots</strong>: workspace boundary information through which the client tells the server, “This is the extent of the area you may work in”</li>
<li><strong>Elicitation</strong>: a feature that allows the server to request additional user input in a structured form while executing a tool</li>
</ul>
<p>This distinction matters because <strong>who decides to invoke or provide something differs</strong>. A Tool runs at the model’s discretion, so an incorrect invocation carries risk, while a Prompt is explicitly chosen by the user. A Resource is chosen by the application by default, but the specification also allows implementations that include resources automatically, based on heuristics or the model’s selection. The three client-side primitives run in the opposite direction: the server asks, and the client decides whether to respond.</p>
<h3 id="two-transports"><a class="anchor" href="#two-transports">Two Transports</a></h3>
<p>There are two <a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" target="_blank" rel="noopener noreferrer">standard transport mechanisms</a>, and the specification also allows other custom transports (MAY). The first is <strong>stdio</strong>, which runs an MCP server as a local subprocess and communicates through standard input and output. It is well suited to locally operating tools such as filesystem and git integrations. The second is <strong>Streamable HTTP</strong>, which layers SSE (Server-Sent Events) streaming over HTTP POST and GET to create near-bidirectional communication. SSE is a way for a server to push data to a client in one direction over an HTTP connection. It is suited to scenarios that occur across a network boundary, such as remote servers, OAuth authentication, multi-client connections, and cloud deployments.</p>
<h3 id="how-an-llm-invokes-an-mcp-tool"><a class="anchor" href="#how-an-llm-invokes-an-mcp-tool">How an LLM Invokes an MCP Tool</a></h3>
<p>Now that we have covered the primitives and transports, let us trace <strong>how an LLM actually discovers and invokes an MCP tool</strong>.</p>
<p>In the 2025-11-25 revision, the following handshake takes place when a connection begins. As of 2026-10-08, the TypeScript SDK’s 1.32.1 and the v2 line’s 2.3.1 also follow this order in their default configuration.</p>
<ul>
<li><strong>Client → server</strong>: <code>initialize</code> request (sends the supported protocol version and client capabilities)</li>
<li><strong>Server → client</strong>: <code>initialize</code> response (server capabilities plus an optional <code>instructions</code> field)</li>
<li><strong>Client → server</strong>: <code>notifications/initialized</code> notification</li>
<li><strong>Client → server</strong>: <code>tools/list</code> request → receives the list of available tools</li>
<li>(Later) The LLM decides to invoke a tool → the client sends <code>tools/call</code> → receives the result</li>
</ul>
<p>The <code>initialize</code> response’s <code>instructions</code> field is where the server sends text describing how its tools should be used. The instructions line in the demo output below shows this value.</p>
<p>How, then, does the tool definition itself enter the LLM’s field of view? An MCP tool definition takes the following JSON Schema form.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark github-light"><code data-language="json" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "name"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "description"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Get current weather information for a location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "inputSchema"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "type"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"object"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "properties"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">"location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">"type"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"string"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } },</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "required"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The host transforms the list returned by <code>tools/list</code> into the <strong><code>tools</code> parameter of the Anthropic Messages API</strong> or the <strong><code>tools</code> parameter of OpenAI function calling</strong>, then includes it in the LLM API request. With Anthropic, supplying the tool parameter <a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" target="_blank" rel="noopener noreferrer">automatically adds a special system prompt</a> that teaches the model how to invoke tools. Its length varies by model. In the documentation table I checked on 2026-10-08, current models added 286 to 675 tokens with <code>tool_choice: auto</code>.</p>
<p>When the LLM decides that it should invoke a tool, its response contains a <code>tool_use</code> block (<code>{"type": "tool_use", "name": ..., "input": ...}</code>), and the response ends with a <code>stop_reason</code> of <code>tool_use</code>. The host receives this, sends <code>tools/call</code> to the actual MCP server, receives the result, places it in a <code>tool_result</code> block in the next user message, and sends it back to the LLM. <strong>This loop continues until <code>stop_reason</code> changes from <code>tool_use</code> to another value, such as <code>end_turn</code> or <code>max_tokens</code>.</strong> What we commonly describe as “the agent working” is essentially a sequence of these call-result-call loops.</p>
<p>Drawn as a picture, there is no line between the model and the MCP server. The model only sees the definitions it received through <code>tools</code>, and the MCP server receives requests from the host, not from the model. What connects the two protocols is the host in the middle.</p>
<p><picture><source type="image/webp" srcset="/content/260524/1.720w.webp 720w, /content/260524/1.1440w.webp 1440w, /content/260524/1.2000w.webp 2000w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/260524/1.png" alt="Arrows labeled tools, tool_use, and tool_result go between the LLM API on the left and the host in the middle, and arrows labeled tools/list and tools/call go between the host and the MCP server on the right. There is no line between the LLM API and the MCP server" style="max-width:min(720px, 100%);height:auto;" width="2000" height="640" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>I ran this conversion to see how short it really is. It ran on 2026-10-08 with Node v24.16.0, <code>@modelcontextprotocol/sdk</code> 1.32.1, and <code>zod</code> 4.6.5. Instead of stdio, the transport is <code>InMemoryTransport</code>, which connects the server and client inside one process, and no LLM was called. The <code>tool_use</code> block was built by hand in the shape shown in Anthropic’s documentation.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { McpServer } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/server/mcp.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { Client } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/client/index.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { InMemoryTransport } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@modelcontextprotocol/sdk/inMemory.js"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { z } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "zod"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// MCP 서버: 도구 하나와 instructions 를 둔다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> server</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> McpServer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, version: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"1.0.0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { instructions: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Use get_weather for current conditions only."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">server.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">registerTool</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Get current weather information for a location"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, inputSchema: { location: z.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() } },</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">location</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ content: [{ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"text"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, text: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">location</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}: 15C, partly cloudy`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트: 같은 프로세스 안에서 서버와 잇고, 클라이언트가 보내는 메서드 이름을 찍는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">clientT</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">serverT</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> InMemoryTransport.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">createLinkedPair</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> send</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> clientT.send.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">bind</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(clientT);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">clientT.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">send</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"C->S"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, m.method, m.params?.protocolVersion </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> send</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(m); };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> server.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">connect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(serverT);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> client</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Client</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"demo-host"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, version: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"1.0.0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">connect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(clientT);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"instructions:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInstructions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 1. tools/list 결과를 LLM API 의 tools 파라미터 모양으로 바꾼다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">tools</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">listTools</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> anthropicTools</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tools.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ name: t.name, description: t.description, input_schema: t.inputSchema }));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> openaiTools</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tools.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"function"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, name: t.name, description: t.description, parameters: t.inputSchema }));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"anthropic:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(anthropicTools[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"openai:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(openaiTools[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 2. 모델이 이런 tool_use 블록을 돌려줬다고 가정한다. input 이 그대로 tools/call 의 arguments 가 된다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> toolUse</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_use"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toolu_demo"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, name: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"get_weather"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, input: { location: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Seoul"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">callTool</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name: toolUse.name, arguments: toolUse.input });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_result:"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ type: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"tool_result"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, tool_use_id: toolUse.id, content: result.content }));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> client.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">close</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>The output of <code>node post-demo.mjs</code> is as follows.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>C->S initialize 2025-11-25</span></span>
<span data-line=""><span>C->S notifications/initialized </span></span>
<span data-line=""><span>instructions: Use get_weather for current conditions only.</span></span>
<span data-line=""><span>C->S tools/list </span></span>
<span data-line=""><span>anthropic: {"name":"get_weather","description":"Get current weather information for a location","input_schema":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"],"$schema":"http://json-schema.org/draft-07/schema#"}}</span></span>
<span data-line=""><span>openai: {"type":"function","name":"get_weather","description":"Get current weather information for a location","parameters":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"],"$schema":"http://json-schema.org/draft-07/schema#"}}</span></span>
<span data-line=""><span>C->S tools/call </span></span>
<span data-line=""><span>tool_result: {"type":"tool_result","tool_use_id":"toolu_demo","content":[{"type":"text","text":"Seoul: 15C, partly cloudy"}]}</span></span></code></pre></figure>
<p>Converting the tool definition is nothing more than renaming fields. MCP’s <code>inputSchema</code> becomes <code>input_schema</code> in Anthropic and <code>parameters</code> in OpenAI. The output also shows that the SDK adds <code>$schema</code> to the schema. The other direction is just as short. Anthropic’s <code>tool_use.input</code> is an object, so it goes straight into <code>tools/call</code> as its <code>arguments</code>. On the result side, text blocks have the same shape and pass straight through, but image and error results have a different shape, so the Anthropic SDK’s MCP helper converts them separately. The OpenAI shape above is the Responses API format. The <code>arguments</code> of a call OpenAI returns is a <a href="https://developers.openai.com/api/docs/guides/function-calling" target="_blank" rel="noopener noreferrer">JSON-encoded string</a>, so it needs one pass through <code>JSON.parse</code> before being handed over. The code above builds only an Anthropic-shaped <code>tool_use</code> block, so this parse step does not appear in the output.</p>
<h3 id="four-things-mcp-adds"><a class="anchor" href="#four-things-mcp-adds">Four Things MCP Adds</a></h3>
<p>So what does MCP add to function calling? Under the 2025-11-25 revision, there are four things.</p>
<ul>
<li><strong>Dynamic discovery</strong>: the tool list is retrieved at runtime through <code>tools/list</code> rather than known at build time. The server can announce through <code>notifications/tools/list_changed</code> that the list has changed during a connection</li>
<li><strong>Stateful session</strong>: a connection is established with <code>initialize</code>, and requests are exchanged within it. There is no dedicated JSON-RPC shutdown message; the session ends by closing the transport</li>
<li><strong>Primitives beyond Tool</strong>: Resource, Prompt, Sampling, Roots, and Elicitation are exposed through capability negotiation. Capability negotiation is the step in <code>initialize</code> where each side announces the features it supports</li>
<li><strong>Bidirectionality</strong>: the server can request a completion from the client’s LLM in reverse through Sampling (deprecated in the 2026-07-28 revision)</li>
</ul>
<h3 id="after-the-2026-07-28-revision"><a class="anchor" href="#after-the-2026-07-28-revision">After the 2026-07-28 Revision</a></h3>
<p>As of 2026-10-08, the revision the official site opens as latest is the <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" target="_blank" rel="noopener noreferrer">2026-07-28 revision</a>, and half of this list changed there. First, the handshake made of <code>initialize</code> and <code>notifications/initialized</code> and protocol-level sessions are gone. Instead, every request carries the protocol version and client capabilities in <code>_meta</code>. That field is a slot MCP reserves for attaching metadata alongside a message’s regular parameters. Servers MUST implement <code>server/discover</code>. It is an RPC that returns the server’s supported protocol versions, capabilities, and server information, and a client can call it before any other request to check the supported versions and capabilities up front.</p>
<p>Requests the server used to send first were replaced by a pattern called Multi Round-Trip Requests (MRTR). Instead of sending a separate request, the server returns an interim result saying it needs more input (<code>input_required</code>), and the client fills in that input and sends the original request again.</p>
<p>Sampling and Roots were deprecated along with Logging. They remain in the specification and still work, but new implementations should not adopt them, and the specification recommends integrating directly with LLM provider APIs instead of Sampling.</p>
<p>The SDKs’ default behavior, however, still follows the old way. TypeScript SDK 1.32.1 has <code>2025-11-25</code> as its latest version constant and does not know the 2026-07-28 revision, and 2.3.1 supports this revision but defaults to <code>legacy</code> version negotiation. The first line of the output above, <code>initialize 2025-11-25</code>, is the result.</p>
<p>What remains of the four, then, is dynamic discovery and the primitives beyond Tool (Resource, Prompt, Elicitation). Dynamic discovery has changed shape slightly as well: list change notifications go only to clients that opt in to the <code>subscriptions/listen</code> stream. In the end, where MCP parts ways with function calling is less about sessions or bidirectionality and more about <strong>a contract for exchanging the tool list and context at runtime</strong>.</p>
<h3 id="when-the-api-becomes-the-mcp-client"><a class="anchor" href="#when-the-api-becomes-the-mcp-client">When the API Becomes the MCP Client</a></h3>
<p>Anthropic’s <a href="https://platform.claude.com/docs/en/agents-and-tools/mcp-connector" target="_blank" rel="noopener noreferrer">MCP connector</a> (beta) shows how much of this contract reaches the model. It is a feature in which the Messages API connects directly to a remote MCP server, and the Limitations section of its documentation says that of the MCP specification’s features, "only tool calls are currently supported", and that "Local STDIO servers cannot be connected directly". The same documentation advises that if you need local servers, MCP prompts, or resources, you manage the connection yourself with an MCP SDK and use the Anthropic SDK’s conversion helpers.</p>
<p>In other words, when MCP is consumed at the function calling layer, only Tool remains. Resource and Prompt mean something only when there is a host to carry them into the screen or the context. OpenAI’s function calling guide also introduces a way to use the functionality of an MCP server as a built-in tool. OpenAI’s <a href="https://developers.openai.com/api/docs/guides/tools-connectors-mcp" target="_blank" rel="noopener noreferrer">MCP servers guide</a> describes only how to list and call tools, and does not say whether Resource or Prompt is supported.</p>
<h3 id="the-attack-surface-dynamic-discovery-opens"><a class="anchor" href="#the-attack-surface-dynamic-discovery-opens">The Attack Surface Dynamic Discovery Opens</a></h3>
<p>Tool descriptions and tool call results pass through the host into the model’s context. So whatever a server writes there, the model reads. Both representative attacks come from this.</p>
<ul>
<li>
<p><strong>Tool Poisoning Attack (TPA)</strong>: an attack named and demonstrated in a PoC by <a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks" target="_blank" rel="noopener noreferrer">Invariant Labs</a> in April 2025. If malicious instructions are hidden inside an MCP server’s tool description, the model reads that text, which the user never sees, and may follow it without the user knowing.</p>
</li>
<li>
<p><strong>Rug Pull</strong>: an attack, described by Invariant Labs in the same post, in which the server changes a tool definition after the user has approved it. As in Elena Cross's example <a href="https://simonwillison.net/2025/Apr/9/mcp-prompt-injection/" target="_blank" rel="noopener noreferrer">quoted by Simon Willison</a>, you approve a safe-looking tool on day 1, and by day 7 that tool has been changed to send your API keys to an attacker; it happens because tool definitions are fetched from the server at runtime rather than at install time. The <a href="https://modelcontextprotocol.io/specification/2025-11-25/server/tools" target="_blank" rel="noopener noreferrer">tool specification</a> recommends (SHOULD) UI that shows which tools are exposed to the model but does not require re-approval of a changed definition, so re-approval falls to the host.</p>
</li>
</ul>
<h2 id="wrapping-up"><a class="anchor" href="#wrapping-up">Wrapping Up</a></h2>
<p>In short, MCP does not replace function calling; it is a standard layered on top of it. The model still invokes tools through the <code>tools</code> parameter and the <code>tool_use</code> loop, and the host translates between the two protocols. In the 2025-11-25 revision, what MCP added was dynamic discovery, a stateful session, primitives beyond Tool, and calls that go from the server to the client. With sessions gone and Sampling deprecated in the 2026-07-28 revision, what remains is a contract for exchanging the tool list and context at runtime. Because of that contract, attacks that poison tool definitions or quietly change them arise in the same place. When you add another MCP server, I recommend checking not only what that server can do but also whether your host tells you when its definitions change.</p>
<p>If MCP is about what to enable the agent to do, what to tell the agent is the job of context files such as <code>CLAUDE.md</code> and <code>AGENTS.md</code>. How those files are read by the agent, and how far their instructions are followed, is covered in <a href="/260529" target="_blank" rel="noopener noreferrer">Context Files</a>.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle" target="_blank" rel="noopener noreferrer">MCP Specification 2025-11-25, Lifecycle</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning" target="_blank" rel="noopener noreferrer">MCP Specification 2026-07-28, Versioning</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>AI</category>
            <category>Developer-Tools</category>
            <category>Claude</category>
            <category>MCP</category>
        </item>
        <item>
            <title><![CDATA[State Management]]></title>
            <link>https://hooninedev.com/en/260518</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260518</guid>
            <pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about State Management. This is not a library comparison. Rather than deciding which tool is better, the goal is to develop a feel for how to think about state and where t...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>State Management</strong>. This is not a library comparison. Rather than deciding which tool is better, the goal is to develop a feel for <strong>how to think about</strong> state and <strong>where to draw its boundaries</strong>.</p>
<p>AI tools such as Claude, ChatGPT, Cursor, Gemini, and Copilot have become deeply embedded in our daily work. Development has accelerated exponentially, but if I am honest, the quality of the services we build does not seem to have kept pace. We are increasingly confronted with as many new bugs as new features, and I often hear people say, "I have no idea why this ended up this way."</p>
<p>As development speeds up, we spend less time examining every line of code in detail. That makes it even more important, in my view, to have the <strong>fundamental skills needed to guide AI in the right direction</strong>. To preserve the quality of the result, we need to recognize problems in AI-generated code and steer it back toward the outcome we want. Those fundamentals may include domain-driven development, abstraction, TDD (Test-Driven Development), effective use of libraries, and building performance advantages.</p>
<p>Yet whenever I ask frontend colleagues—and colleagues in other IT roles—"What is the hardest part of frontend development?" the answer I hear most often is remarkably consistent: <strong>"Managing state flow."</strong></p>
<p>This article explores why managing state flow is so difficult and what kind of judgment and intuition we need to cultivate to handle it well.</p>
<h2 id="what-is-state"><a class="anchor" href="#what-is-state">What Is State?</a></h2>
<p>Before getting into the main discussion, let us begin with the most fundamental question: what exactly do we mean by "state"?</p>
<p>While studying frontend development, I often read articles by <a href="https://blog.hoseung.me/2021-12-05-state-management" target="_blank" rel="noopener noreferrer">hoseung.me</a>. There, state is described as <strong>"any data that can affect the UI."</strong> A like count, the contents of a shopping cart, whether a modal is open, an input value, information about the signed-in user, the currently selected tab, search results, and whether something is loading—all of these are state.</p>
<p>The official React documentation defines it more formally. The page is titled <a href="https://react.dev/learn/state-a-components-memory" target="_blank" rel="noopener noreferrer">"State: A Component's Memory"</a>, which can be understood as <strong>"the mechanism by which a component retains data between renders and tells React to trigger a re-render when that data is updated."</strong> In other words, it is data that persists over time, changes in response to events, and causes the UI to be redrawn when it changes. One more important point: state is <strong>isolated to each component instance.</strong> Even if the same component appears ten times on a page, each instance has its own independent state. This fact connects directly to the later discussion of where state should live.</p>
<p>Both definitions point to the same idea: state is <strong>"a value that changes over time and affects rendering."</strong> A constant that never changes is not state. Primitive design tokens fixed at build time are not state, but a dark mode setting that the user can toggle is. (Strictly speaking, the values themselves resolve according to the dark or light theme state. The "theme selection" is the state, while the tokens are more accurately understood as a mirror reflecting that state.)</p>
<p>There is one important caveat: <strong>not all state lives in components.</strong> Some state lives in cookies; some in localStorage, sessionStorage, or IndexedDB; and some in the URL. When server-resident data is brought to the client and cached, that cache also becomes a form of state. Even browser-managed values such as scroll position and the history stack sometimes need to be treated as state because they determine how our application behaves.</p>
<h2 id="why-is-it-so-difficult"><a class="anchor" href="#why-is-it-so-difficult">Why Is It So Difficult?</a></h2>
<p>At first, handling state sounds simple enough. Create the state you need, pass it where it is needed, and correctly handle updates and resets. What could be so difficult about that?</p>
<p>Keep that question in mind and open a page from the service you are currently working on.</p>
<p>How many components does that page contain? Even a simple page may consist of dozens of components, while a complex one may contain hundreds arranged in a tree. Each component may hold its own state, share state with a sibling, or receive state from a parent. State can also carry across pages. Some state must survive a refresh, while other state should disappear when the tab closes.</p>
<p>This is the real reason state is difficult to manage: <strong>we cannot see at a glance where countless pieces of state are declared, how they are updated, or when they cease to exist.</strong> As the number of components with similar roles grows, both naming state and tracing the code that changes it become increasingly difficult.</p>
<p>An invisible web begins to form. A click in component A invalidates B's data; invalidating B closes C's UI; and closing C discards the form input. If that chain is not explicit anywhere in the code, we have to reconstruct the web in our heads whenever we debug a bug.</p>
<p>How, then, should we untangle this web? I believe the first step is recognizing that <strong>"state comes in different kinds."</strong></p>
<h2 id="not-all-state-is-the-same"><a class="anchor" href="#not-all-state-is-the-same">Not All State Is the Same</a></h2>
<p><a href="https://kentcdodds.com/blog/application-state-management-with-react" target="_blank" rel="noopener noreferrer">Kent C. Dodds</a> divides state into <strong>Server Cache</strong> (information that exists on the server and is held by the client for fast access) and <strong>UI State</strong> (information that exists only in the UI to control interface behavior). We often make mistakes when we lump the two together.</p>
<p>The <a href="https://tanstack.com/query/latest/docs/framework/react/guides/does-this-replace-client-state" target="_blank" rel="noopener noreferrer">official TanStack Query documentation</a> defines TanStack Query as a server-state library that manages asynchronous work between the server and the client, while tools such as Redux, MobX, and Zustand are client-state libraries. (They can store asynchronous data, but doing so is inefficient.)</p>
<p>The central point is clear: <strong>Server State and Client State are different problems.</strong> Server State is asynchronous, can be changed by other users, and becomes stale over time. Client State is synchronous, under our control, and disappears on refresh. (More precisely, when the page unloads, <strong>the JavaScript runtime restarts, and the component tree and its state in heap memory are reclaimed together.</strong> When the component mounts again, it starts over from the initial value of <code>useState</code>.) If we try to handle both with the same tool, we must implement patterns such as cache invalidation, background refetching, and optimistic updates ourselves.</p>
<p>I take this one step further and divide frontend state into <strong>seven categories</strong>. To be clear, these seven categories do not separate neatly along a single axis. They mix storage location, source, lifecycle, and role, so a single piece of state may belong to several categories at once. Rather than treating this as a perfect taxonomy, think of the categories as <strong>questions to ask when deciding how state should be managed</strong>.</p>
<ul>
<li><strong>Local State</strong>: State used only within one component or a narrow subtree</li>
<li><strong>Global State</strong>: State that must be shared across the application</li>
<li><strong>Server State</strong>: State whose source of truth is the server and whose client-side copy is a cache</li>
<li><strong>Form State</strong>: Temporary state that exists while the user is entering data</li>
<li><strong>URL State</strong>: Shareable state that lives in the address bar and survives refreshes</li>
<li><strong>External State</strong>: State outside React, such as cookies, localStorage, sessionStorage, and IndexedDB</li>
<li><strong>State Guard</strong>: Logic that blocks, allows, or validates access and actions based on combinations of state, rather than state itself</li>
</ul>
<p>Beyond these categories, there is workflow state that may need to be modeled as a state machine and real-time collaborative state built on WebSocket or CRDTs.</p>
<p>Let us examine why each category calls for different tools and what kind of judgment we should apply to it.</p>
<h2 id="local-state"><a class="anchor" href="#local-state">Local State</a></h2>
<p>This is the simplest kind of state. It is used only within a single component, and the outside world neither needs nor is entitled to know about it. Examples include whether a modal is open, whether a toggle button is on or off, hover state, and a search term currently being entered.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SearchBox</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">input</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{query} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">e</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e.target.value)} />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>This much is probably familiar. The genuinely difficult question with Local State is deciding <strong>"where should this state live?"</strong></p>
<p>In <a href="https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster" target="_blank" rel="noopener noreferrer">Kent C. Dodds's article on State Colocation</a>, he explains that <strong>people are accustomed to "lifting up" state, but when the code changes, they rarely move that state back to where it belongs by "colocating" it.</strong></p>
<p>Lifting state is the natural thing to do when sibling components need to share it. Because both siblings need to see the same data, we move the state to their common parent and pass it down through props.</p>
<p>The problem arises when those siblings no longer need the state. We are much less likely to <strong>move it back down</strong> into a child. As a result, parent components accumulate state that has little to do with them, and every parent re-render causes the entire child tree to re-render as well.</p>
<p>The first principle for Local State, then, is this: <strong>to make the code faster and simpler, keep state as close as possible to the code that uses it.</strong> If state is used by only one child of a component, there is no reason for the parent to own it. Move it into that child. The parent becomes lighter as a result.</p>
<h2 id="global-state"><a class="anchor" href="#global-state">Global State</a></h2>
<p>Global State must be accessible from anywhere in the application. Authentication information, theme, language, and notifications such as toasts are common candidates.</p>
<p>The difference between Local State and Global State is not simply where they live. They make different <strong>contracts about how they may be referenced</strong>. Local State promises that <strong>"this has meaning only inside this component,"</strong> while Global State publishes a codebase-wide promise that <strong>"this value can be referenced by this name from anywhere in the application."</strong> The cost of that promise is the essence of Global State.</p>
<p>Creating a piece of Global State is, in effect, adding another <strong>implicit dependency across the entire application</strong>.</p>
<h2 id="server-state"><a class="anchor" href="#server-state">Server State</a></h2>
<p>We put data returned by an API into Client State and manage loading and error states ourselves with booleans—until we finally ask, <strong>"Why am I writing the same boilerplate every time?"</strong></p>
<p>Tanner Linsley, the lead maintainer of TanStack, puts it this way: <strong>"Client State is synchronous and predictable. Server State is asynchronous, shared across multiple components, and requires careful handling of caching, background refetching, and error states."</strong> Server State is therefore <strong>fundamentally a different species</strong> from Client State. It should not be handled with the same tool.</p>
<p>What makes Server State difficult is not the tooling but <strong>the nature of the data itself</strong>.</p>
<p>The data the client displays belongs to the server. What the client holds is merely <strong>a snapshot from a particular moment in time</strong>. As time passes, this data becomes stale. It is also asynchronous, can fail, and moves through states such as pending, error, and success.</p>
<p>The most important fact is that <strong>responses are not guaranteed to arrive in the order requests were sent</strong>. Imagine typing "react" quickly into a search box. Requests for r → re → rea → reac → react are sent in that order, but if the response for "react" arrives first and the response for "rea" arrives afterward, the UI will display the results for "rea". Preventing this requires accounting for <strong>concurrency hazards (race conditions)</strong> that would otherwise force us to implement AbortController logic or request ID tracking by hand every time.</p>
<h2 id="form-state"><a class="anchor" href="#form-state">Form State</a></h2>
<p>Forms are a peculiar kind of state. Their values change intensely while the user is typing, but usually disappear once the form is submitted. They are not shared elsewhere, and in most cases there is nowhere else they need to be stored.</p>
<p>The problem is that this <strong>intense rate of change</strong> can be expensive. If every keystroke causes a React re-render, input lag can become noticeable in a large form. And a form does more than simply hold values. <strong>Validation, dirty checks, submission status, error messages, and multistep flows</strong> all bring several kinds of state to life within a single form at once.</p>
<p>A multistep form such as a three-step checkout flow is often <strong>expected to preserve progress even if the page is refreshed midway through</strong>. If its values live only in useState, a refresh wipes them all out. It is more natural to store them in <strong>sessionStorage</strong> (temporary, tab-scoped storage) or the <strong>URL</strong> (for shareable steps). In other words, depending on its lifecycle requirements, Form State combines with <strong>External State</strong> or <strong>URL State</strong>.</p>
<h2 id="url-state"><a class="anchor" href="#url-state">URL State</a></h2>
<p>Imagine a search page where users can filter by category, sort order, and page number. If we keep these values in useState, three problems arise at once.</p>
<ul>
<li>Refreshing the page resets every filter</li>
<li>Sharing the URL with a friend shows them the unfiltered page</li>
<li>Pressing the Back button does not restore the previous filters</li>
</ul>
<p>A natural solution is to <strong>put the state in the URL.</strong> The URL is effectively a free persistent store with built-in support for refreshes, sharing, and navigation history.</p>
<pre><code>/products?category=shoes&#x26;sort=price-desc&#x26;page=2
</code></pre>
<p>That single URL contains the complete state: <strong>"page 2 of the shoes category, sorted by price in descending order."</strong> There is no need to keep a separate copy in useState.</p>
<p>When is the URL the right place for state? <strong>A URL is a public interface.</strong> Passwords, authentication tokens, and temporary notes that users would not want others to see must not go into it. Nor should values that change too frequently—such as a search term updated on every keystroke—be written directly to the URL, because they will fill the history stack with noise. In those cases, apply the change after a debounce or avoid polluting history by using <code>push</code> only when appropriate and choosing <code>replace</code> for updates that should not add an entry.</p>
<p>Values in a URL are <strong>always strings</strong>. Numbers, booleans, arrays, and objects must be serialized and deserialized. URLs must also follow the rules of <a href="https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams" target="_blank" rel="noopener noreferrer">percent encoding</a>, which gives characters such as <code>&#x26;</code>, <code>=</code>, Korean text, and spaces special handling. Implementing all of this by hand every time quickly becomes a breeding ground for bugs.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> URLSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(location.search);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> page</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(params.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "1"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(page </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">navigate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`?${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">()</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">page</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQueryState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"page"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, parseAsInteger.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">withDefault</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span></code></pre></figure>
<p>Libraries such as <a href="https://nuqs.dev/" target="_blank" rel="noopener noreferrer">nuqs</a> solve both problems through the concept of a <strong>parser</strong>. Parsers such as <code>parseAsInteger</code>, <code>parseAsBoolean</code>, and <code>parseAsJson</code> handle serialization, deserialization, and types together. The library supports most common environments, including both the App Router and Pages Router in Next.js, React Router v6/v7, TanStack Router, and Remix.</p>
<p>Does that mean we can put unlimited amounts of state into a URL? Separate from serialization and typing concerns, one final constraint remains. <a href="https://datatracker.ietf.org/doc/html/rfc7230" target="_blank" rel="noopener noreferrer">RFC 7230</a> does not set an exact limit, but recommends that servers support at least 8,000 octets (an octet is the term used in networking and data communications to refer unambiguously to a group of eight bits, or one byte). Browser limits also vary. Modern browsers generally permit anything from 8 KB to tens of thousands of characters, but <strong>search engines, social media Open Graph and sharing pipelines, and some gateways may truncate URLs at around 2 KB</strong>. So do not treat the URL as boundless storage. Keep only the <strong>essential, shareable filters</strong> there, and leave the rest to sessionStorage or server-side storage.</p>
<h2 id="external-state"><a class="anchor" href="#external-state">External State</a></h2>
<p>React knows only about state within its own world. Our applications, however, constantly communicate with the world outside React. State that lives there can persist and change independently of React's lifecycle. Here, External State includes <strong>Cookie, localStorage, sessionStorage, and IndexedDB</strong>.</p>
<p>How should we choose among these storage mechanisms? I usually evaluate them along four dimensions: <strong>lifetime, capacity, synchronicity, and security</strong>.</p>
<p>For <strong>authentication tokens</strong>, the <a href="https://owasp.org/www-community/HttpOnly" target="_blank" rel="noopener noreferrer">OWASP recommendation</a> is to prefer <strong>HttpOnly + Secure cookies</strong>. Because JavaScript can access localStorage, <strong>an XSS compromise can expose the token directly</strong>. Some security guides recommend a hybrid pattern: keep the <strong>access token in memory and the refresh token in an HttpOnly cookie</strong>. Use localStorage for persistent, non-sensitive data that changes infrequently, and sessionStorage for data that should disappear with the tab. IndexedDB is commonly used for offline caches, large datasets, and files.</p>
<p>Cookies and Web Storage (local/session) store <strong>strings only</strong>. Storing an object therefore requires <code>JSON.stringify</code>/<code>JSON.parse</code>. JSON, however, has limitations.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ when: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → { "when": "2026-05-19T..." } — Date becomes a string</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ map: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"a"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]]) });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → { "map": {} } — Map is lost entirely</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ value: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → "{}" — the undefined field is omitted</span></span></code></pre></figure>
<p><code>Date</code> becomes a string during a JSON round trip, while <code>Map</code>, <code>Set</code>, and <code>undefined</code> can lose data. With the default behavior, <code>BigInt</code> makes <code>JSON.stringify</code> throw a <code>TypeError</code>, so serialization fails entirely. Whenever you put an object into external storage, remain conscious of <strong>which types may be lost, transformed, or cause serialization to fail</strong>, and introduce a serialization adapter when necessary.</p>
<p>The real difficulty of External State is that <strong>React does not detect its changes automatically</strong>. Writing a value to localStorage does not cause a React component to re-render. There are generally three patterns for solving this.</p>
<ul>
<li><strong>Wrap it in a custom hook (useLocalStorage) that synchronizes External State with React state.</strong> This is lightweight, but a hand-rolled implementation must handle edge cases such as multiple tabs, SSR, and tearing.</li>
<li>Use the <code>useSyncExternalStore</code> hook introduced in React 18 to <strong>"synchronize with state outside React."</strong> This <strong>ensures that tearing does not occur during concurrent rendering.</strong> It is the standard tool for connecting React to localStorage, browser APIs, and external stores.</li>
<li>Use the first-class external storage integrations provided by state libraries, such as Zustand's <code>persist</code> middleware or Jotai's <code>atomWithStorage</code>.</li>
</ul>
<p>Here is one more important principle: <strong>the moment we bring External State into React, synchronization becomes our responsibility.</strong> What happens when another tab updates it? What if the server changes a cookie? What if a user edits localStorage directly through the browser's developer tools? These scenarios often become some of the richest sources of bugs.</p>
<h2 id="state-guard"><a class="anchor" href="#state-guard">State Guard</a></h2>
<p>The final category is somewhat different. It is not state itself, but <strong>logic that uses a combination of state to block, allow, or validate a particular flow</strong>.</p>
<p>The most common example is an <strong>Auth Guard</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ProtectedRoute</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isAuthenticated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isLoading) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">isAuthenticated) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> children;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Here, the <code>isAuthenticated</code> state controls the routing flow. That is guard logic. There are many kinds of guards: Auth Guards (authenticated or not), permission guards (specific roles or permissions), flow guards (entry-point branching), and validation guards (enabling a step), among others.</p>
<p>Guard logic tends to accumulate in one place. It is common for a single component to contain every condition: <strong>"if the user is not signed in, go to the login page; if they lack permission, show a 403; if the cart is empty, go to the product page; if the user is suspended, show the suspension notice."</strong> As a guard grows, it becomes harder to debug which condition blocked the flow and where.</p>
<p>A good guard <strong>checks only one thing.</strong> Combine guards through Composition.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">AuthGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">RoleGuard</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> role</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"admin"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">FlowGuard</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> require</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"cartHasItems"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CheckoutPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">FlowGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">RoleGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">AuthGuard</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>Each guard makes only one decision, while the tree structure handles composition. Adding a new guard does not require changing any existing guard.</p>
<p>When designing guards, what happens next—<strong>where the user is sent and how the flow continues</strong>—deserves even more thought than the act of blocking access itself. A guard that blocks without a fallback ends in a blank screen or an infinite spinner.</p>
<p>One of the most common bugs occurs when <strong>protected content briefly flashes before the guard's asynchronous check finishes</strong>. Validating an authentication token and fetching permissions are usually asynchronous. During that interval, <code>isAuthenticated</code> may temporarily be <code>undefined</code> or <code>false</code>. <strong>Unless the loading state is handled explicitly, protected content may be exposed during that gap, or the user may be redirected to the login page incorrectly.</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Ignores loading and handles only missing data => incorrect</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Treat loading as a first-class state (early return) => correct</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isLoading) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Spinner</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Navigate</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> to</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"/login"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> children;</span></span></code></pre></figure>
<p>Two models are commonly used when implementing permission guards.</p>
<ul>
<li><strong>RBAC(Role-Based Access Control)</strong>: Grants permissions by role—for example, "an admin can view all user information." It is simple and fast, but the number of roles can explode as permissions become more granular</li>
<li><strong>ABAC(Attribute-Based Access Control)</strong>: Determines permissions from combinations of attributes—for example, "the user is the author of the post, belongs to the same team, or is an admin." It is highly expressive but more difficult to implement and debug</li>
</ul>
<p>Patterns such as the <a href="https://tanstack.com/router/v1/docs/framework/react/how-to/setup-rbac" target="_blank" rel="noopener noreferrer">TanStack Router RBAC guide</a>, which places guards in <code>beforeLoad</code> at the router level, are recommended. The key is that <strong>permission checks should be expressed as data—a list of roles and permissions—rather than scattered throughout the code</strong>. That way, changing an authorization policy remains a <strong>data change</strong>.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>Let us sum up. State Management is difficult not because the libraries are difficult, but because <strong>we often forget that state comes in different kinds</strong> and overlook the fact that each kind requires different tools and a different way of thinking.</p>
<p>Keep Local State as close as possible; question whether Global State truly needs to be global; treat Server State as a cache; separate forms from the domain; use the URL more actively; understand the responsibility that comes with external storage; and compose small, focused guards. These are the fundamentals of working with the seven categories.</p>
<p>The judgment that ties them together can ultimately be reduced to four questions.</p>
<ul>
<li>Where is the Single Source of Truth for this data?</li>
<li>Is this a value that can be derived, or does it truly need to be stored?</li>
<li>Are any combinations of this state impossible?</li>
<li>Does this state really belong in this location?</li>
</ul>
<p>I believe the surest way to develop sound judgment and intuition is to ask these questions whenever we build a new screen, review a PR, or receive code generated by AI.</p>
<p>As I said at the beginning, AI will remain beside us for a long time. We will spend less and less time inspecting every line. Yet that will only make the ability to answer small questions such as <strong>"Where should this state live?"</strong> more valuable. Asking AI to "add another useState here" is easy. Knowing what new strand that single line adds to the web of our application depends entirely on the judgment of the person reading the code.</p>
<p>There is no single right answer. But there is a clear difference between <strong>"creating state without understanding what state is"</strong> and <strong>"creating state with deliberate awareness of its category and location."</strong> Before readers write their next line of <code>useState</code>, I hope they will pause for a moment and ask, "Which category of state is this?"</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/learn/choosing-the-state-structure" target="_blank" rel="noopener noreferrer">React, Choosing the State Structure</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://legacy.reactjs.org/blog/2018/06/07/you-probably-dont-need-derived-state.html" target="_blank" rel="noopener noreferrer">React, You Probably Don't Need Derived State</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://xstate.js.org/" target="_blank" rel="noopener noreferrer">XState</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.syncfusion.com/blogs/post/react-state-management-libraries" target="_blank" rel="noopener noreferrer">Top 5 React State Management Tools in 2026</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>State-Management</category>
            <category>React</category>
            <category>Architecture</category>
        </item>
        <item>
            <title><![CDATA[Domain Models]]></title>
            <link>https://hooninedev.com/en/260418</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260418</guid>
            <pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about where domain logic should live on the frontend. This is for frontend developers whose business rules, such as tax calculations or filing status checks, are scattered...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>where domain logic should live on the frontend</strong>.</p>
<p>This is for frontend developers whose business rules, such as tax calculations or filing status checks, are scattered across components, hooks, and utils, so that every rule change means hunting down several files. By the end, you will know how to move those rules into pure functions that do not depend on React, and how to judge where that separation should stop.</p>
<p>All the examples use the comprehensive income tax calculation, a domain I have been paying attention to.</p>
<h2 id="where-should-domain-logic-live-on-the-frontend"><a class="anchor" href="#where-should-domain-logic-live-on-the-frontend">Where Should Domain Logic Live on the Frontend?</a></h2>
<p>Before answering, let us agree on one term. In this post, a <strong>domain model</strong> holds business concepts together with the rules that operate on them. It differs from a <strong>data model</strong>, such as an API response type, which only defines the shape in which data travels. This difference is also why, when components depend directly on the response structure, the whole frontend is shaken every time the backend schema changes. How the four terms (domain, domain model, domain object, and domain object model) differ is covered separately in <a href="/260413" target="_blank" rel="noopener noreferrer">Domain Terms</a>.</p>
<p><a href="https://khalilstemmler.com/about/" target="_blank" rel="noopener noreferrer">Khalil Stemmler</a>, who has a deep interest in software design, initially argued that "business logic does not belong on the frontend." He later revised his position, saying, "Almost everything we do architecturally on the backend can and should also be done on the frontend."</p>
<p>I agree with this position. Of course, the frontend must not become the <strong>Single Source of Truth</strong> for business logic. That is the backend's role. But the frontend clearly has <strong>domain logic of its own</strong>.</p>
<p>Consider a case where "the estimated refund must update in real time based on the information the user enters." If this calculation logic exists only on the backend, an API call must be made every time the user changes a single digit in their income. The UI pauses for a network round trip, and a fast typist can trigger a flood of unnecessary requests. Even with debounce, a delay of a few hundred milliseconds is enough to undermine the experience of a "real-time preview." <strong>Ultimately, calculations that require immediate feedback have to be performed directly on the frontend, which means some logic can only be performed there.</strong></p>
<h3 id="when-domain-logic-is-mixed-into-a-component"><a class="anchor" href="#when-domain-logic-is-mixed-into-a-component">When Domain Logic Is Mixed into a Component</a></h3>
<p>Consider a comprehensive income tax preview screen. It shows the estimated tax in real time as the user enters income information. The code below is a common example in which domain logic and UI logic are intertwined.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxPreviewPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">총수입</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set총수입</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">경비율</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set경비율</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.641</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">인적공제대상인원</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">set인적공제대상인원</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 종합소득금액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 경비율;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 소득공제합계</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 인적공제대상인원 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_500_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 과세표준</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, 종합소득금액 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 소득공제합계);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 14_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.06</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 50_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.15</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_260_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 88_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.24</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5_760_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 150_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.35</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 15_440_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    calculatedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 과세표준 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.38</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 19_940_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 기납부세액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 총수입 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.033</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> refundOrPayment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 기납부세액 </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> calculatedTax;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>...&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Can you see the problem? <strong>Business rules established by tax law</strong>—"KRW 1.5 million in personal deductions per person," "eight progressive tax brackets," and "3.3% withholding"—are embedded directly in a React component. Tax law changes every year. When such rules are scattered across components, each revision sends us hunting for every place that needs to change. And if the QA team maintains E2E scenarios, the testing cost will be substantial as well.</p>
<p>Eventually it becomes difficult to distinguish view logic from business logic, and the code turns into a tangle of conditionals and custom hooks.</p>
<h3 id="let-us-separate-the-domain-logic"><a class="anchor" href="#let-us-separate-the-domain-logic">Let Us Separate the Domain Logic</a></h3>
<p>Let us borrow the central principle from Alex Bespoyasov's Clean Architecture approach: separate domain logic into <strong>pure functions that do not depend on a framework</strong>.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>The domain is the core that distinguishes one application from another. You can think of the domain as something that will not change if we move from React to Angular.</p></div><div class="quote-original" lang="en"><p>The domain is the core that distinguishes one application from another. You can think of the domain as something that won't change if we move from React to Angular.</p></div></blockquote>
<p>Let us refactor the tax calculation example above.</p>
<p>First, we define the domain types and rules, keeping related information together.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  grossAmount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  expenseRate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  personalCount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  pensionPaid</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  additionalDeductions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> PERSONAL_DEDUCTION_PER_PERSON</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1_500_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> WITHHOLDING_RATE</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0.033</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TAX_BRACKETS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { limit: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">14_000_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, rate: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.06</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, progressiveDeduction: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /** ...구간들... **/</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>Then we separate the domain logic into pure functions.</p>
<p>The logic for income calculation, deductions, tax base, tax, and refunds discussed above is extracted into a <code>computeFullTax</code> function. Each stage is then broken down into smaller pure functions. If the result type is inferred with <code>ReturnType&#x3C;typeof computeFullTax></code>, there is no need to declare a separate interface.</p>
<p>The component then does nothing but "use" the domain logic.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { computeFullTax } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/tax"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxPreviewPage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setIncome</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    grossAmount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    expenseRate: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.641</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setDeductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Deductions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    personalCount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    pensionPaid: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    additionalDeductions: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> computeFullTax</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(income, deductions);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">IncomeForm</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{income} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setIncome} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">DeductionForm</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{deductions} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onChange</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{setDeductions} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TaxResultSummary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> result</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{result} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>What has changed?</p>
<ul>
<li>The <strong>table of eight progressive tax brackets</strong> (<code>TAX_BRACKETS</code>) is gathered in one place, so when tax law changes, only <code>domain/tax.ts</code> needs to be updated.</li>
<li>The <strong>calculation pipeline</strong> is cohesive within a single <code>computeFullTax</code> function, making the entire flow visible at a glance. (It is grouped into one function to keep the example simple, but in a real project it would be appropriate to divide it further by purpose, such as income calculation, deduction calculation, and tax calculation.)</li>
<li>The <strong>component focuses only on "how to present it."</strong> A change in tax rates does not require modifying the component.</li>
<li>Even if we migrate from React to another framework, <code>domain/tax.ts</code> <strong>does not change</strong>.</li>
</ul>
<p>Once domain logic is separated, testing becomes surprisingly simple. This is especially important in the tax domain, where <strong>calculation accuracy directly affects the user's money</strong>.</p>
<p>Pure functions containing tax calculation logic need neither React Testing Library nor <code>render</code> nor <code>screen.getByText</code>. Supply an input and check the output—that is all. Cases such as "a 6% rate up to KRW 14 million," "zero tax when the tax base is zero," and "the refund on KRW 30 million of freelance income" can each be expressed as a one-line <code>it</code>. Domain unit tests naturally establish boundaries for component separation, while the test code also serves as documentation.</p>
<h2 id="anemic-domain-model"><a class="anchor" href="#anemic-domain-model">Anemic Domain Model</a></h2>
<p>In the previous section, we separated <strong>calculation logic</strong>. But domain logic also includes <strong>state-transition rules</strong> and <strong>authorization decisions</strong>. Questions such as "Can this filing be edited right now?", "Can it be submitted?", and "Can the claim method be changed?" fall into these categories. Separating these rules presents an easy trap: the <strong>Anemic Domain Model</strong>, a term coined by Martin Fowler.</p>
<p>An Anemic Domain Model is a state in which <strong>types are well defined in the domain language, but the rules that operate on them have been scattered outside the domain</strong>. Consider the tax filing domain. The type is tidy.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// types/filing.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "submitted"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amended"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "regular"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "late"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>But the rules for decisions and transitions concerning this type are buried elsewhere.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// utils/filingHelpers.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canAmendFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// components/FilingDetail.tsx</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingDetail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 같은 도메인 규칙을 컴포넌트 안에 다시 작성한다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> canEdit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// hooks/useSubmitFiling.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> handleSubmitFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The same domain rule exists in three different forms across utils, a component, and a hook. If a requirement comes in saying, "The eligibility conditions for claims are changing," we must hunt down every place that needs updating, while any one we miss will make an incorrect decision somewhere on the site. Fowler criticized such code as <strong>"little more than procedural code wearing the skin of an object-oriented model."</strong></p>
<p>The solution is the same one we applied to calculation logic in the previous section: <strong>put the rules next to the type</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "submitted"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "reviewing"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amended"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "regular"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "late"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도메인 규칙은 도메인 옆에 둔다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> filing.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Now every filing-related rule is managed in a single place: <code>domain/filing.ts</code>. Any component can call <code>canAmend(filing)</code>, and when a rule changes, only this one file needs to be updated. The key is to <strong>treat the type and the rules that operate on it as one unit</strong>. A partial separation that puts only the type in the domain folder while moving its rules to utils may look clean, but it is still anemic.</p>
<h2 id="the-translation-layer-between-api-responses-and-the-domain-model"><a class="anchor" href="#the-translation-layer-between-api-responses-and-the-domain-model">The Translation Layer Between API Responses and the Domain Model</a></h2>
<p>There is one more factor to consider in practice: the backend API response structure and the frontend domain model do not always match. This is even more true for a tax service integrated with government systems. Data from Korea's National Tax Service Hometax system is full of abbreviations and coded values, so it is unlikely to arrive in the same shape as the frontend domain model.</p>
<p>What we need here is a <strong>transformation layer (Mapper)</strong>. Rather than passing the API response type all the way into components, we first refine it into a domain type. A single pure function is enough.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { Income } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/tax"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> HometaxIncomeResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  총수입금액</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  경비율</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  소득유형코드</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ... 나머지 약어 필드들</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> toIncome</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> HometaxIncomeResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Income</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    총수입_금액: response.총수입금액,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    경비_비율: response.경비율,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>This converts abbreviated fields such as <code>총수입금액</code> and <code>경비율</code>, as well as code-based classifications in the API response, to suit the frontend domain <strong>in one place</strong>. Values such as the income-type code that need to be expanded into an enum can be handled with a small lookup table inside the mapper. Even if the field names in the Hometax API change, only the mapper needs to be updated.</p>
<h2 id="utility-functions-and-domain-logic"><a class="anchor" href="#utility-functions-and-domain-logic">Utility Functions and Domain Logic</a></h2>
<p>When separating domain logic, one question inevitably arises: <strong>"Isn't this just a utility function?"</strong></p>
<p>Consider the following two functions.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatCurrency</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">amount</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toLocaleString</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">()</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}원`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculateTax</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">taxableBase</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> bracket</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TAX_BRACKETS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">bracket</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> taxableBase </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bracket.limit);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">floor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(taxableBase </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">*</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bracket.rate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> bracket.progressiveDeduction);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>formatCurrency</code> is pure <strong>presentation logic</strong> that converts a number to a string. Adding the unit "won" and thousands separators is not a business rule; it concerns how a value is shown to the user. By contrast, <code>calculateTax</code> contains a <strong>business rule grounded in tax law</strong>: applying eight progressive tax brackets. It is a domain rule that must apply in exactly the same way even if there is no UI.</p>
<p>Here is the criterion I use in practice.</p>
<blockquote>
<p><strong>If this logic disappears, does the business break, or only the screen?</strong></p>
</blockquote>
<p>If the business breaks, it is domain logic. If only the screen breaks, it is presentation logic. This one question can identify most boundaries.</p>
<table>
<thead>
<tr>
<th>Criterion</th>
<th>Domain Logic</th>
<th>Utility/Presentation Logic</th>
</tr>
</thead>
<tbody>
<tr>
<td>What breaks without it?</td>
<td>Tax calculation</td>
<td>The screen (UI) looks wrong</td>
</tr>
<tr>
<td>What if the framework changes?</td>
<td>Remains unchanged</td>
<td>May change</td>
</tr>
<tr>
<td>Is it in the product spec?</td>
<td>"Tax base × rate - progressive deduction"</td>
<td>"Separate amounts with commas"</td>
</tr>
<tr>
<td>Same logic on the backend?</td>
<td>Exists or should exist</td>
<td>No (frontend-only concern)</td>
</tr>
</tbody>
</table>
<p>Reality, however, is not this tidy. The hardest cases are those that <strong>look like domain logic but are actually presentation logic</strong>.</p>
<p>Consider the code below. It was classified as domain logic because it takes the domain concept FilingStatus as an argument. But is it really domain logic?</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// domain/filing.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getStatusBadgeColor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> colors</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    draft: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"gray"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    submitted: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"blue"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    reviewing: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"yellow"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completed: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"green"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    amended: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"purple"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> colors[status];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getStatusDisplayText</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> labels</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    draft: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"작성 중"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    submitted: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"제출 완료"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    reviewing: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"검토 중"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completed: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"신고 완료"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    amended: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"경정청구"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> labels[status];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Although <code>getStatusBadgeColor</code> and <code>getStatusDisplayText</code> use the domain concept <code>FilingStatus</code>, what they do is <strong>screen presentation</strong>. Changing a badge color does not break the business at all. Putting such functions in <code>domain/filing.ts</code> makes the domain module increasingly bloated and mixes genuine domain logic with presentation logic.</p>
<h3 id="separating-the-domain-model-and-viewmodel"><a class="anchor" href="#separating-the-domain-model-and-viewmodel">Separating the Domain Model and ViewModel</a></h3>
<p>There is a practical way to solve this problem: <strong>separate the ViewModel into its own file within the same domain folder</strong>. Rather than <code>.ui.ts</code>, using the name <code>.viewModel.ts</code> connects naturally to the ViewModel concept in the MVVM pattern. The name immediately communicates its role as "a layer that transforms domain data for the screen."</p>
<pre><code>domains/
└── filing/
    ├── filing.ts              # 순수 도메인 모델 + 도메인 로직
    ├── filing.viewModel.ts    # ViewModel (표현 변환 계층)
    ├── filing.test.ts         # 도메인 로직 테스트
    └── filingMapper.ts        # API ↔ 도메인 변환
</code></pre>
<p>Move the <code>getStatusBadgeColor</code> and <code>getStatusDisplayText</code> functions we saw earlier directly into <code>filing.viewModel.ts</code>. Transformations that expand filing types into Korean labels, such as <code>getFilingTypeLabel(type: FilingType): string</code>, are gathered there as well. <code>filing.ts</code> is responsible only for business rules, while <code>filing.viewModel.ts</code> is responsible only for screen presentation.</p>
<p>The key is the <strong>direction of dependency</strong>. <code>filing.viewModel.ts</code> imports <code>filing.ts</code>, but <code>filing.ts</code> never imports <code>filing.viewModel.ts</code>. The domain knows nothing about presentation; presentation knows about the domain. This can be seen as a miniature version of Robert C. Martin's Dependency Rule.</p>
<p>I placed the files in the same folder because I believe files that change together should live in the same directory. If the <code>FilingStatus</code> type gains a new value (such as <code>'rejected'</code>), both <code>filing.ts</code> and <code>filing.viewModel.ts</code> must be updated. Since they are in the same folder, the scope of the change is visible at a glance.</p>
<h2 id="boundaries-and-cohesion"><a class="anchor" href="#boundaries-and-cohesion">Boundaries and Cohesion</a></h2>
<p>Just as important as separating domain logic is deciding <strong>where to draw the boundaries</strong>. Here are several boundary decisions I often encounter in practice.</p>
<p>The data handled by a frontend comes from roughly four sources.</p>
<ul>
<li><strong>Server data</strong>: received in an API response</li>
<li><strong>Derived data</strong>: calculated from server data</li>
<li><strong>UI state</strong>: used to control the screen and reflect user interactions</li>
<li><strong>User input</strong>: values currently being entered in a form</li>
</ul>
<p>Combining all four in one type pollutes the domain model.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안티패턴: 모든 것이 섞인 타입</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 서버 데이터 (도메인)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 파생 데이터 (도메인)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  refundAmount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  canAmend</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // UI 상태 (표현)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  isExpanded</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  activeStep</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // 임시 상태</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  editingDeductions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Deduction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>This type puts domain concepts, UI state, and temporary data in one basket. Every change to <code>activeStep</code> effectively updates the Filing domain. (Changing a form step is not a business event.)</p>
<p>The improvement is to divide the types along their boundaries. The <strong>domain model</strong> contains only business concepts such as <code>id</code>, <code>status</code>, and <code>determinedTax</code>; <strong>UI state</strong> (<code>FilingFormViewState</code>) contains only screen controls such as <code>isExpanded</code> and <code>activeStep</code>; and <strong>form state</strong> (<code>DeductionEditForm</code>) contains only temporary data being entered.</p>
<p>This gives each type <strong>a single reason to change</strong>. A domain type changes only when tax law changes, UI state only when the screen design changes, and form state only when the input UX changes.</p>
<h3 id="keep-things-that-change-together-together"><a class="anchor" href="#keep-things-that-change-together-together">Keep Things That Change Together Together</a></h3>
<p>Eric Evans's DDD includes the concept of an <strong>Aggregate</strong>: "a cluster of related objects treated as a single unit." We do not need to apply this concept wholesale on the frontend, but its central principle—<strong>keep data and rules that change together together</strong>—is worth borrowing.</p>
<p>In a tax service, for example, <code>Income</code> and <code>ExpenseRate</code> always change together. When the income type changes, the applicable expense rate changes, and the comprehensive income calculation is affected as well. These should therefore be kept together in one file, <code>domain/tax.ts</code>.</p>
<p>By contrast, <code>TaxFiling</code> can change independently of tax calculation. A change to the filing's state-transition rules does not affect tax-rate calculation logic. It is therefore appropriate to separate it into <code>domain/filing.ts</code>.</p>
<pre><code>이렇게 묻자: "A가 변할 때 B도 반드시 변해야 하는가?"
  → Yes: 같은 모듈에 둔다 (Income + ExpenseRate + TaxBracket)
  → No: 분리한다 (Tax 계산 ↔ Filing 상태관리)
</code></pre>
<h2 id="class-vs-functional-style"><a class="anchor" href="#class-vs-functional-style">Class vs. Functional Style</a></h2>
<p>At this point, a fundamental question may come to mind. Every example so far has used a combination of <code>interface</code> and pure functions. Would cohesion be more natural if we represented the domain with a class?</p>
<p>That is a fair point. A class-based representation of the domain binds data and behavior into a single object, making cohesion immediately visible in the code structure.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">class</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  constructor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> filingType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    public</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> readonly</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209"> determinedTax</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  ) {}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "F-001"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  2025</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">  "regular"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  547200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>With a class-based approach, behavior belongs to the data. The subject is also explicit at the call site. <code>filing.canAmend()</code> is as intuitive as reading natural language: the subject (filing) and verb (canAmend) are clearly bound together. It is like writing <code>jihoon.eat('감자탕')</code> and immediately reading, "Jihoon eats gamjatang."</p>
<p>By contrast, the functional style looks like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">eat</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"jihoon"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"감자탕"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>In the functional style, the data exists outside the function. The first call takes <code>filing</code> as an argument and performs an action, while <code>eat</code> takes <code>jihoon</code> and <code>감자탕</code> as its arguments.</p>
<p>As a result, the bond between subject and verb is looser. To know that the <code>canAmend</code> function concerns <code>TaxFiling</code>, we have to open the file or inspect its type signature. If functions such as <code>canAmend(filing)</code>, <code>canEdit(filing)</code>, and <code>calculateTax(taxableBase)</code> are mixed in the same file, it may be difficult to see at a glance which domain each function belongs to.</p>
<h3 id="so-should-we-use-classes"><a class="anchor" href="#so-should-we-use-classes">So Should We Use Classes?</a></h3>
<p>Honestly, the answer is <strong>"it depends."</strong> In my experience, however, there are practical reasons classes are not a silver bullet in a React + TypeScript environment.</p>
<h4 id="friction-with-react-state-management"><a class="anchor" href="#friction-with-react-state-management">Friction with React State Management</a></h4>
<p>React state management fits most naturally with <strong>plain objects</strong>. <code>useState</code> and <code>useReducer</code> can technically hold any value, and Redux DevTools does not itself remove a class instance's prototype. If Redux/Zustand persistence middleware stores and restores state as JSON, however, a class instance loses its methods and prototype in the <code>JSON.stringify</code> → <code>JSON.parse</code> cycle and becomes a plain object. The props boundary from a React Server Component to a Client Component has a different constraint: it accepts only supported serializable values, so an arbitrary class instance cannot be passed through it in the first place.</p>
<p>Consider the code below.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">filing</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"F-001"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2025</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"regular"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>Updating React state alone does not stop <code>filing</code> from being an instance of <code>TaxFilingModel</code>. If Redux/Zustand persistence saves and restores it as JSON, however, the restored value may be a plain object without methods, so an innocent call to <code>filing.canAmend()</code> can produce a runtime error. Passing it from a React Server Component to a Client Component fails earlier because a class instance is not a supported serializable prop value.</p>
<h4 id="the-difficulty-of-guaranteeing-immutability"><a class="anchor" href="#the-difficulty-of-guaranteeing-immutability">The Difficulty of Guaranteeing Immutability</a></h4>
<p>React detects state changes based on <strong>referential equality</strong>. If a method on a class instance mutates internal state with something like <code>this.items.push(...)</code>, the reference stays the same and React does not trigger a rerender. In the end, <code>addDeduction(item)</code> has to return a new instance every time—something like <code>return new DeductionList([...this.items, item])</code>. That undermines the class advantage of "encapsulated state mutation" and leaves code that is not very different from functional updates.</p>
<h3 id="strategies-for-achieving-cohesion-in-a-functional-style"><a class="anchor" href="#strategies-for-achieving-cohesion-in-a-functional-style">Strategies for Achieving Cohesion in a Functional Style</a></h3>
<p>Then how can we improve the loose cohesion of code such as <code>eat('jihoon', '감자탕')</code> in a functional style? Here are three approaches I have found effective.</p>
<h4 id="create-cohesion-with-a-module-namespace"><a class="anchor" href="#create-cohesion-with-a-module-namespace">Create Cohesion with a Module Namespace</a></h4>
<p>This is the most straightforward approach. Make the file (module) itself correspond to a domain, and use a namespace when importing it. We can use the <code>domain/filing.ts</code> defined earlier as-is.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> *</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> as</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FilingModel </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "../domain/filing"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">FilingModel.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filing);</span></span></code></pre></figure>
<p><code>FilingModel.canAmend(filing)</code> is not quite as compact as <code>filing.canAmend()</code>, but the code makes it immediately clear that this function belongs to the Filing domain. It also eliminates the risk of functions from several domains being mixed together.</p>
<h4 id="the-domain-subject-goes-first"><a class="anchor" href="#the-domain-subject-goes-first">The Domain Subject Goes First</a></h4>
<p>Another convention expresses cohesion in a functional style: <strong>always make the first argument the "subject of the behavior."</strong> Consistent signatures such as <code>canAmend(filing)</code> and <code>calculateTotalIncome(income)</code> allow <code>canAmend(filing)</code> to read as "ask canAmend about filing." This also resonates with the Unix pipeline mindset (<code>data |> transform</code>). In fact, Go's method receiver follows precisely this pattern, and Rust's <code>impl</code> blocks accept <code>self</code> as the first argument from the same underlying idea.</p>
<h4 id="bundle-behavior-with-a-factory"><a class="anchor" href="#bundle-behavior-with-a-factory">Bundle Behavior with a Factory</a></h4>
<p>This pattern is useful when we miss the cohesion of a class. A factory function returns a domain object and its behavior together.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">data,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canSubmit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "draft"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.determinedTax </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      data.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.filingType </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "amendment"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filing</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFilingModel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(rawFiling);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canAmend</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filing.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">canEdit</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>This pattern combines the expressiveness of a class (<code>filing.canAmend()</code>) with the practicality of composing behavior through an object literal. Because the returned object has function properties, it is not itself JSON-serializable data. It also creates new function objects each time, but at the scale of data handled on the frontend, this is rarely a performance problem.</p>
<h2 id="how-far-should-we-separate-things"><a class="anchor" href="#how-far-should-we-separate-things">How Far Should We Separate Things?</a></h2>
<p>Clean Architecture describes an ideal structure with three or four layers and defined Ports/Adapters. Applying this structure to every project in the real world, however, can become over-engineering.</p>
<p>Here are the practical guidelines I use.</p>
<ul>
<li><strong>Separate domain types from API response types.</strong> Whether using an <code>interface</code> or a <code>type</code>, define the domain concepts used by the frontend in separate files.</li>
<li><strong>Move logic containing business rules out of components.</strong> It does not have to live in a <code>domain/</code> folder. What matters is making it a pure function that does not depend on React.</li>
<li><strong>Transform API responses into the domain model in one place.</strong> Whether that is a mapper function or a Zod schema, create a structure where changing that one place prevents the change from propagating.</li>
</ul>
<p>As a project grows more complex, the following may also be worth considering.</p>
<ul>
<li><strong>Divide folders by Bounded Context.</strong> The <a href="https://frontend-fundamentals.com/" target="_blank" rel="noopener noreferrer">Toss Frontend Chapter</a> also emphasizes the principle, "Place files that change together in the same directory." Dividing folders by domain naturally reveals domain boundaries through import paths.</li>
<li><strong>Introduce a Use Case layer.</strong> When combinations of domain logic become complex, an Application layer is needed to wrap a scenario such as "retrieve income information → apply expense rate → calculate deductions → calculate tax → finalize refund" into a single function.</li>
</ul>
<pre><code>src/
├── domains/
│   ├── tax/
│   │   ├── tax.ts                  # 세액 계산 도메인 (세율, 공제, 계산 파이프라인)
│   │   ├── tax.viewModel.ts        # 세액 표현 (금액 포맷, 구간 라벨)
│   │   ├── tax.test.ts             # 세액 계산 테스트
│   │   └── incomeMapper.ts         # 홈택스 API ↔ 도메인 변환
│   ├── filing/
│   │   ├── filing.ts               # 신고 상태 도메인 (상태 전이, 권한)
│   │   ├── filing.viewModel.ts     # 신고 표현 (상태 배지, 라벨)
│   │   ├── filing.test.ts
│   │   └── filingMapper.ts
│   └── deduction/
│       ├── deduction.ts            # 공제 항목 도메인 (자격 조건, 한도)
│       └── deduction.viewModel.ts
├── hooks/                           # React 의존 로직
├── components/                      # UI 컴포넌트
└── api/                             # API 호출
</code></pre>
<p>Even within the single domain of tax, <strong>tax calculation (tax)</strong>, <strong>filing management (filing)</strong>, and <strong>deductions (deduction)</strong> are separated into independent subdomains. A change in tax rates does not affect filing state-transition logic, and adding a deduction does not alter the filing-submission flow. This is a practical application of Bounded Context.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>To summarize, the frontend has domain logic of its own, and its rules belong not in components but in pure functions next to the types. API responses are converted into domain types in one mapper, presentation is split off into a ViewModel, and things that change together are kept together.</p>
<p>Putting this into practice on the frontend is not merely a matter of splitting folders. It means <strong>deliberately reasoning about several layers of boundaries</strong>. "Is this a business rule or presentation logic?" "Is this data domain state or UI state?" "Is this function sufficiently cohesive?" Simply making a habit of asking these questions will naturally improve the structure of the code.</p>
<p>Of course, not every project needs every layer of Clean Architecture. Splitting a simple CRUD app into four layers and applying the factory pattern to every domain would be a case where the cure is worse than the disease. Between the elegant cohesion of classes and the practical flexibility of functions, the right answer is determined by the project's complexity and the team's context.</p>
<p>There is no single right answer. But there is a clear difference between <strong>"writing code without knowing what the domain is"</strong> and <strong>"recognizing the domain, reasoning about boundaries, and separating things deliberately."</strong> I hope readers will take a moment to ask themselves, "What is the domain here, and where should this code live?" in their own projects.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215" target="_blank" rel="noopener noreferrer">Eric Evans, Domain-Driven Design (Book)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html" target="_blank" rel="noopener noreferrer">Robert C. Martin, Clean Architecture</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://khalilstemmler.com/articles/typescript-domain-driven-design/ddd-frontend/" target="_blank" rel="noopener noreferrer">Khalil Stemmler, Does DDD Belong on the Frontend?</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://bespoyasov.me/blog/clean-architecture-on-frontend/" target="_blank" rel="noopener noreferrer">Alex Bespoyasov, Clean Architecture on Frontend</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://toss.tech/article/income-qa-e2e-automation" target="_blank" rel="noopener noreferrer">Toss, The Journey to E2E Automation</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>Architecture</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[Domain Terms]]></title>
            <link>https://hooninedev.com/en/260413</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260413</guid>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about how domains, domain models, domain objects, and domain object models differ from one another. This is for frontend developers who have read about DDD and wondered wh...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>how domains, domain models, domain objects, and domain object models differ from one another</strong>.</p>
<p>This is for frontend developers who have read about DDD and wondered whether these words all point to the same thing. By the end, you will be able to place the four terms on a single line from abstract to concrete, and explain why an API response type is not a domain model.</p>
<p>I have encountered these words quite often as a developer, yet when someone asks, "What exactly is a domain?" it is not easy to give a clear answer. (Honestly, when I first started programming, I thought domain meant the "www" kind.) All the examples in this post use the comprehensive income tax calculation.</p>
<h2 id="domain"><a class="anchor" href="#domain">Domain</a></h2>
<p>Let us begin with the most fundamental question. What is a <strong>domain</strong>?</p>
<p>In his book <strong>Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)</strong>, Eric Evans defines a domain as follows.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>A sphere of knowledge, influence, or activity.</p></div><div class="quote-original" lang="en"><p>"A sphere of knowledge, influence, or activity."</p></div></blockquote>
<p>Put simply, the domain is the <strong>problem space that we intend to solve through programming</strong>. If we are building a tax filing service, "tax filing" is the domain; if we are building an insurance claims platform, "insurance claims" is the domain. A domain is not code. It is a real-world problem space that exists before the software does.</p>
<p>What does this mean for frontend developers? Ultimately, the UI we build is a <strong>window</strong> that lets users see and manipulate the domain. When developing tax-refund services such as Toss Income or 3o3, whose primary domain is tax, we are expressing domain concepts such as income types, expense rates, income deductions, tax credits, and refund amounts through the UI. Frontend developers therefore need a deep understanding of the domain they work with. In other words, knowing <strong>"what problem this service solves"</strong> is just as important as being good at rendering UI components.</p>
<p>But even a single domain called "tax" contains countless subdomains when examined closely. This is true even of the comprehensive income tax calculation pipeline I only understand at a high level.</p>
<p><picture><source type="image/webp" srcset="/content/260413/1.720w.webp 720w, /content/260413/1.1090w.webp 1090w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/260413/1.png" alt="The income tax calculation pipeline, from gross income through to payment or refund, with each step colour-coded into the income, deduction, tax, and filing subdomains" width="1090" height="566" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>Each stage of this pipeline is a subdomain with its own rules and data. Within the broad domain of "tax," the detailed domains of Income, Deduction, Tax, and Filing are intertwined. How these should be divided in code is the central question of domain modeling.</p>
<h2 id="domain-model"><a class="anchor" href="#domain-model">Domain Model</a></h2>
<p>Then what is a domain model? How is a domain different from a "domain model"?</p>
<p><a href="https://martinfowler.com/eaaCatalog/domainModel.html" target="_blank" rel="noopener noreferrer">Martin Fowler</a> defines a domain model as an object model of the domain that incorporates both behavior and data. Eric Evans goes one step further.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>A system of abstractions that describes selected aspects of a domain and can be used to solve problems related to that domain. — Eric Evans</p></div><div class="quote-original" lang="en"><p>A system of abstractions that describes selected aspects of a domain and can be used to solve problems related to that domain.</p></div></blockquote>
<p>The key is <strong>"selective abstraction."</strong> A domain model does not contain everything in the real world. Just as a film director does not capture every scene in reality but selects only the scenes needed for the story, a domain model <strong>selects and structures only the aspects needed to solve the problem</strong>.</p>
<p>There is one important point here. A domain model does not necessarily have to be code. It might be a diagram on a whiteboard, or a shared mental model in the minds of team members. Ultimately, the term domain model itself can refer to a concept independent of software.</p>
<p>This is where frontend developers are particularly prone to confusion. They see the structure of an API response and think, "So this is the domain model." But that is a <strong>data model</strong>, not a domain model.</p>
<p>The distinction between a data model and a domain model is as follows.</p>
<table>
<thead>
<tr>
<th>Category</th>
<th>Domain Model</th>
<th>Data Model</th>
</tr>
</thead>
<tbody>
<tr>
<td>Purpose</td>
<td>Express business concepts and rules</td>
<td>Define storage/transfer structures</td>
</tr>
<tr>
<td>Language</td>
<td>Business terms (tax base, tax credit, refund)</td>
<td>Technical terms (string, number, array)</td>
</tr>
<tr>
<td>Contains</td>
<td>Data + behavior (rules)</td>
<td>Data structure only</td>
</tr>
<tr>
<td>Example</td>
<td>"The rate is 6% for a tax base up to KRW 14M"</td>
<td><code>{ taxableBase: number, taxRate: number }</code></td>
</tr>
</tbody>
</table>
<p>A data model defines "the shape in which data is exchanged," while <strong>a domain model defines "what that data means to the business and what rules it follows."</strong> If we fail to distinguish the two, components become directly dependent on the API response structure, and the entire frontend is thrown into disarray whenever the backend schema changes.</p>
<h2 id="domain-object"><a class="anchor" href="#domain-object">Domain Object</a></h2>
<p>If a domain model is a system of concepts, a <strong>domain object</strong> is a concrete implementation of one of those concepts in code.</p>
<p>In <a href="https://www.codewithjason.com/difference-domains-domain-models-object-models-domain-objects/" target="_blank" rel="noopener noreferrer">an article by Jason Swett</a>, who runs Code with Jason, he defines a domain object as follows.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Any object in my object model that also exists as a concept in my domain model, I would call a domain object.</p></div><div class="quote-original" lang="en"><p>Any object in my object model that also exist as a concept in my domain model I would call a domain object.</p></div></blockquote>
<p>In other words, if the domain model contains a concept called "comprehensive income" and the code contains a type called <code>Income</code>, that <code>Income</code> is a domain object. But not every object in code is a domain object. Things such as <code>HttpClient</code>, <code>LocalStorageAdapter</code>, and <code>useDebounce</code> are technical tools, not domain concepts.</p>
<h3 id="entity-and-value-object"><a class="anchor" href="#entity-and-value-object">Entity and Value Object</a></h3>
<p>Evans classifies domain objects into three categories: <strong>Entity</strong>, <strong>Value Object</strong>, and <strong>Service</strong>. (Martin Fowler calls this the "Evans Classification.") A Service is a separate concept that represents "a domain operation that does not naturally belong to a particular object." Because the focus of this section is how data is identified, we will concentrate on Entities and Value Objects.</p>
<p>An <strong>Entity</strong> is an object with a unique identity that persists across time and different representations. A tax filing (TaxFiling), taxpayer (Taxpayer), or income record (IncomeRecord) is identified by a unique ID; even if its properties change, it remains the same Entity as long as its ID is the same. Even when the deductions on a filing are edited, it is still the same filing unless the filing ID changes.</p>
<p>A <strong>Value Object</strong> is an object whose meaning comes solely from the combination of its properties, and two Value Objects are considered equal when all their property values are equal. Money, a tax rate (TaxRate), and a tax bracket (TaxBracket) are objects whose values themselves carry the meaning. A "6% tax rate" is simply a "6% tax rate" wherever it is used.</p>
<p>Why does this distinction matter on the frontend? Let us look at the code below.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxpayerName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  taxYear</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FilingStatus</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isSameFiling</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TaxFiling</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.id;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  currency</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "KRW"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "USD"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isSameMoney</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  a.amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.amount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.currency </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.currency;</span></span></code></pre></figure>
<p>TaxFiling is an Entity because it uses its id as the basis of identity. (Merely having an id field does not make something an Entity; the key is that "the id determines whether two objects are the same or different.") Money has no id and is identified only by the combination of amount and currency; it is considered the same value when all properties are equal.</p>
<p>Entities use ID-based comparison; Value Objects use property-based comparison. Making this distinction explicit naturally clarifies the state-management logic for determining whether data is the same or different. When updating an item in a list, for example, an Entity can be found and replaced by ID, while a Value Object can be replaced immutably.</p>
<h2 id="domain-object-model"><a class="anchor" href="#domain-object-model">Domain Object Model</a></h2>
<p>We now understand the "domain model" and the "domain object," but what is a <strong>domain object model</strong>?</p>
<p>Surprisingly, I could not find a commonly agreed-upon definition. A substantial body of literature treats "domain model," "domain object model," "conceptual model," and "analysis object model" as <strong>effectively synonymous</strong>—different names for the conceptual model created during object-oriented analysis.</p>
<p>Another perspective, however, sees it as a more distinct layer. A representative explanation is that the <strong>object model is the point where a domain model is translated into actual code</strong>.</p>
<p>Under this second view, an <strong>object model</strong> is the structure of <strong>every object in the system's code</strong>. This includes technical tools such as <code>HttpClient</code> and <code>useDebounce</code>. Within it, the <strong>subset of objects that represent domain concepts, together with the relationships among them</strong>, is the <strong>domain object model</strong>. This also aligns with the object-oriented modeling tradition, which has defined an "object model" as the static structure of a system—its classes, properties, operations, and relationships.</p>
<p>I find this perspective more practical for frontend developers because the code we actually write always mixes domain objects with technical objects.</p>
<p>Ultimately, <strong>domain → domain model → domain object model → domain object</strong> is a progression from the abstract to the concrete. The domain is the broadest, and the domain object is the most concrete. Thus, the area we actually wrestle with when writing frontend code is <strong>how to structure the domain object model—the types that express domain concepts and the relationships among them</strong>.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>To summarize, a <strong>domain</strong> is the problem space we are trying to solve; a <strong>domain model</strong> is a conceptual system that selectively abstracts that problem; a <strong>domain object model</strong> is, in the view I adopted here, the implementation of that conceptual system in code; and a <strong>domain object</strong> is an individual object within that implementation.</p>
<p>How this distinction plays out in code, that is, where domain logic such as tax calculation should live outside components and how far to separate it, is the subject of <a href="/260418" target="_blank" rel="noopener noreferrer">Domain Models</a>.</p>
<p>Next time you look at an API response type, I hope you will ask yourself at least once: "Is this a data model or a domain model?"</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215" target="_blank" rel="noopener noreferrer">Eric Evans, Domain-Driven Design (Book)</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>Architecture</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[Reflections on Refactoring the Second Toss Frontend Fundamentals Mock Exam]]></title>
            <link>https://hooninedev.com/en/260328</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260328</guid>
            <pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to share my experience refactoring the project from the second Toss Frontend Fundamentals mock exam. I have always been interested in code review and refactoring, so I decided to ...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to share my experience refactoring the project from the second Toss Frontend Fundamentals mock exam.</p>
<p>I have always been interested in code review and refactoring, so I decided to take on Toss's Frontend Fundamentals mock exam, which presented the challenge in an intriguing format. The task was to refactor a provided meeting room reservation app. It also came with tests, giving me a safety net for verifying that the refactoring had not broken any functionality.</p>
<p>In the end, I spent two days on the refactoring. Here are my reflections on the process.</p>
<h2 id="my-first-encounter-with-the-code"><a class="anchor" href="#my-first-encounter-with-the-code">My First Encounter with the Code</a></h2>
<p>The first thing I did after opening the code was <strong>read the test specifications</strong>. Tests are the most honest documentation of what an application is supposed to do. I skimmed through <code>App.easy.spec.tsx</code> and <code>App.hard.spec.tsx</code> to understand the application's overall requirements.</p>
<p>I then turned to the implementation itself, where two monolithic components immediately stood out.</p>
<ul>
<li><code>ReservationStatusPage</code> was a component of 385 lines, with date selection, timeline visualization, reservation detail tooltips, the user's reservation list, and cancellation functionality all packed into a single file.</li>
<li><code>RoomBookingPage</code> was a component of roughly 300 lines, with filters, the room list, reservation creation logic, and URL parameter synchronization all tangled together.</li>
</ul>
<p>As I read through the code, rather than immediately deciding that it “needed improvement,” I first focused on <strong>classifying the characteristics of the code</strong>. I distinguished between code that contained domain knowledge, code that behaved like a utility, and code that belonged purely to the UI layer.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도메인 정보: 장비 라벨, 타임 슬롯 등 비즈니스 상수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  tv: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'TV'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, whiteboard: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화이트보드'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, video: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화상장비'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, speaker: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'스피커'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 유틸리티: 날짜 포맷, 시간 변환</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> timeToMinutes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">time</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 서버 상태: 인라인 useQuery, useMutation 호출</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">rooms</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [] } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'rooms'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], getRooms);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [] } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reservations'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, date], () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// UI + 비즈니스 로직 혼재: 필터링, 정렬, 충돌 감지가 JSX 사이에 산재</span></span></code></pre></figure>
<p>Once I had classified the code this way, it became much easier to see where to begin. I added brief comments to each area of the code to note possible directions for improvement. (It felt a little like migrating the jQuery-based project I encountered when I first joined my current company.)</p>
<p>So where should I start?</p>
<h2 id="developing-a-refactoring-strategy"><a class="anchor" href="#developing-a-refactoring-strategy">Developing a Refactoring Strategy</a></h2>
<p>I planned to carry out the refactoring in the following order.</p>
<ol>
<li><strong>Handle server code</strong>: separate queries and mutations</li>
<li><strong>Separate domain logic</strong>: create Equipment, Room, and Reservation models</li>
<li><strong>Define types</strong>: organize the type system around the domain models</li>
<li><strong>Extract utility functions</strong>: date formatting, timeline calculations, and so on</li>
<li><strong>Separate the UI layer</strong>: divide components into coherent units by concern</li>
<li><strong>Abstract and separate concerns</strong>: handle errors/loading and manage query keys</li>
</ol>
<p>I chose this order so I could work <strong>from the outside of the dependency structure inward</strong>. The idea was to organize the infrastructure—the server code and utilities—first, establish the domain models next, and refine the UI last. If I split the UI components first, I might end up moving still-unorganized domain logic and query code back and forth across multiple components.</p>
<p>With the strategy in place, it was time to execute it one step at a time.</p>
<h2 id="organizing-the-server-code-and-utilities-first"><a class="anchor" href="#organizing-the-server-code-and-utilities-first">Organizing the Server Code and Utilities First</a></h2>
<h3 id="extracting-the-date-formatting-utility"><a class="anchor" href="#extracting-the-date-formatting-utility">Extracting the Date Formatting Utility</a></h3>
<p>The first thing I tackled was the <code>formatDate</code> function because the same function had been defined inline on both pages.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// utils/formatYYYYMMDD.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatYYYYMMDD</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> year</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getFullYear</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> month</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getMonth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'0'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> String</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'0'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">year</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">month</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">date</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>It was a small change, but it was an important first commit in the refactoring. Starting with the most independent part with the fewest side effects and confirming that the tests still passed served as a kind of <strong>warm-up</strong>.</p>
<h3 id="extracting-the-react-query-hooks"><a class="anchor" href="#extracting-the-react-query-hooks">Extracting the React Query Hooks</a></h3>
<p>Next, I moved the <code>useQuery</code> and <code>useMutation</code> calls that had been written directly inside the components into separate files. I used the <code>queryOptions</code> pattern to turn the query configuration into reusable units.</p>
<p>During this process, I also explicitly defined the API response types in <code>remotes.ts</code>. Values that had previously flowed through the code as <code>any</code> now had clear types such as <code>GetRoomsResponse</code> and <code>GetReservationsResponse</code>.</p>
<p>With the infrastructure layer organized, it was time to turn to the domain models.</p>
<h2 id="separating-the-domain-models"><a class="anchor" href="#separating-the-domain-models">Separating the Domain Models</a></h2>
<p>The most important turning point in the refactoring was <strong>moving the domain models into a dedicated <code>models/</code> directory</strong>.</p>
<p>In the original code, business constants such as <code>EQUIPMENT_LABELS</code> and <code>TIME_SLOTS</code> were declared at the top of component files. Types for <code>Room</code> and <code>Reservation</code> existed only in the server handler (<code>_tosslib/server/types.ts</code>), while the client code used values that were effectively treated as <code>any</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// models/equipment.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  tv: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'TV'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, whiteboard: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화이트보드'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, video: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'화상장비'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, speaker: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'스피커'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> keyof</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> typeof</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> EQUIPMENT_LABELS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> ALL_EQUIPMENT</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">EQUIPMENT_LABELS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// models/reservation.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  floor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  capacity</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  roomId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  end</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  attendees</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Why is separating domain models important? When business logic is coupled to UI components, changing that logic requires inspecting the component's rendering logic as well. When it exists independently in a <code>models/</code> directory, changes to business rules can be made separately from the UI. Perfect separation is difficult in practice, of course, but the key is at least to create a structure where people can predict that <strong>“this logic will be here.”</strong></p>
<p>Once the domain models were separated, how much lighter could the UI become?</p>
<h2 id="breaking-apart-the-components"><a class="anchor" href="#breaking-apart-the-components">Breaking Apart the Components</a></h2>
<h3 id="reservationstatuspage"><a class="anchor" href="#reservationstatuspage">ReservationStatusPage</a></h3>
<p>This commit produced the most dramatic change and took the most time. I split the 385-line monolithic component as follows.</p>
<pre><code>ReservationStatusPage/
├── index.tsx                    # 페이지 레벨
└── components/
    ├── DateSelector.tsx         # 날짜 선택 UI
    ├── ReservationTimeline.tsx  # 타임라인
    └── MyReservation.tsx        # 내 예약 목록 + 취소
</code></pre>
<p>My criterion for separating the code was <strong>“Does this code have independent meaning?”</strong> Timeline visualization is an independent concern: it takes reservation data for a date and renders a grid. The user's reservation list is another independent concern: it retrieves and cancels the user's reservations. There was no reason for them to live in the same file.</p>
<p>After the split, <code>index.tsx</code> served only as an <strong>orchestrator</strong>. It managed state, displayed messages, and composed the child components, while delegating the actual data fetching and rendering details to those components.</p>
<h3 id="roombookingpage"><a class="anchor" href="#roombookingpage">RoomBookingPage</a></h3>
<p>I applied the same principle to the booking page.</p>
<pre><code>RoomBookingPage/
├── index.tsx                    # 페이지 레벨
├── components/
│   ├── BookingFilter.tsx        # 날짜, 시간, 인원, 장비, 층 UI
│   └── AvailableRoomList.tsx    # 예약 가능 방 목록
└── hooks/
    └── useBookingParams.ts      # URL searchParams 기반 상태 관리
</code></pre>
<p>One interesting decision emerged during this process. At first, I introduced <code>react-hook-form</code> and <code>zod</code> to validate the form. In the end, however, I removed them and replaced them with a custom <code>useBookingParams</code> hook. I will explain that decision in more detail later.</p>
<p>At this point, a natural question arises: how far should abstraction go?</p>
<h2 id="finding-the-right-level-of-abstraction"><a class="anchor" href="#finding-the-right-level-of-abstraction">Finding the Right Level of Abstraction</a></h2>
<p>This was the part I wrestled with most during the mock exam.</p>
<h3 id="how-far-should-nested-conditionals-be-unraveled"><a class="anchor" href="#how-far-should-nested-conditionals-be-unraveled">How Far Should Nested Conditionals Be Unraveled?</a></h3>
<p>The logic that determines whether a room is available combines several conditions: whether its capacity is sufficient, whether it has the required equipment, whether it is on the preferred floor, and whether its schedule overlaps with another reservation. In the original code, all of these conditions were written inline inside a single <code>filter</code> callback.</p>
<p>When I extracted this logic into <code>models/roomFilter.ts</code>, I separated each condition into a <strong>named function</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isEnoughCapacity</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">attendees</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.capacity </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> attendees;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hasRequiredEquipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">equipment</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Equipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  equipment.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">every</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">eq</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.equipment.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">includes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(eq));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isOnPreferredFloor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">floor</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> floor;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hasNoTimeConflict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">end</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  !</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">reservations.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">some</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservation</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.roomId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> room.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.start </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> end </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> reservation.end </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> start);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> filterAvailableRooms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">rooms</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reservations</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Reservation</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[], </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Params</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Room</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[] {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> rooms</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">room</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      isEnoughCapacity</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.attendees) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      hasRequiredEquipment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.equipment) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      isOnPreferredFloor</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, params.floor) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      hasNoTimeConflict</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(room, reservations, params.date, params.startTime, params.endTime)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sort</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">a</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (a.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.floor) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.floor </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.floor;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> a.name.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">localeCompare</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.name);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The key here was that I extracted a condition into a function <strong>only when there was a clear name for the abstraction</strong>. Names such as <code>isEnoughCapacity</code> and <code>hasRequiredEquipment</code> let readers predict what the functions do without seeing their implementations. If the name had to be vague, like <code>processRoomConditions</code>, the abstraction could instead impose additional cognitive load on the reader.</p>
<p>This is not to say that my approach is the one correct answer. My criterion was simply <strong>“Can I predict the behavior from the function name alone?”</strong> If so, I abstracted it. If not, I thought leaving it inline might actually make the code easier to read.</p>
<h3 id="searchparams-vs-form-state"><a class="anchor" href="#searchparams-vs-form-state">searchParams vs. Form State</a></h3>
<p>I also spent considerable time deciding where the booking filter state should live. In the original code, each filter value was managed with <code>useState</code> and synchronized with the URL searchParams through <code>useEffect</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 원본: useState + useEffect 동기화 방식</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'date'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatDate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">startTime</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setStartTime</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'startTime'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ... 6개의 개별 상태</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Record</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {};</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (date) params.date </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> date;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ... 모든 상태를 searchParams에 동기화</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(params, { replace: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [date, startTime, endTime, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span></code></pre></figure>
<p>I initially tried managing it as a form with <code>react-hook-form</code> and <code>zod</code>. Ultimately, however, I removed them and replaced them with a <code>useBookingParams</code> hook that uses <strong>searchParams as the Single Source of Truth</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// useBookingParams: searchParams가 곧 상태</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">searchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> params</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useMemo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    date: searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'date'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatYYYYMMDD</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    startTime: searchParams.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'startTime'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }), [searchParams]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> updateParam</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">K</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> extends</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> keyof</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> K</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BookingParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">K</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    setSearchParams</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">prev</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      // 기존 파라미터 병합 후 업데이트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }, { replace: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, [setSearchParams]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { params, updateParam };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The core rationale for this decision was that <strong>“it does not make sense for these states to evolve separately.”</strong> If <code>useState</code> and <code>searchParams</code> each hold their own state, they can fall out of sync depending on when synchronization occurs. If searchParams alone holds the state, however, the URL becomes the application state and the synchronization problem disappears entirely. As a bonus, sharing the URL reproduces the same filter state for another user.</p>
<p>I found similar considerations in other participants' retrospectives. <strong>“We standardized on URL searchParams as the Single Source of Truth,” and “We chose to consolidate the individual filter props into a single <code>filter</code> object.”</strong> The expressions differed, but the underlying recognition was the same: <strong>“Scattered state needs to be grouped into a single concept.”</strong></p>
<h2 id="resilience"><a class="anchor" href="#resilience">Resilience</a></h2>
<h3 id="suspense-and-errorboundary"><a class="anchor" href="#suspense-and-errorboundary">Suspense and ErrorBoundary</a></h3>
<p>I added error and loading handling after the component structure had been finalized. The order matters because you can only decide where to place a boundary after the component tree has been established.</p>
<p>Using the <code>react-error-boundary</code> library, I wrapped each independent data-fetching unit in an <code>ErrorBoundary</code> and <code>Suspense</code>. If the timeline fails, the user's reservation list should still render normally, and vice versa.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 각 영역이 독립적으로 에러/로딩을 처리 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{ErrorFallback} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">resetKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[date]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Loading</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"예약 현황을 불러오는 중..."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ReservationTimeline</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{date} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{ErrorFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Loading</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"내 예약을 불러오는 중..."</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MyReservation</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onCancel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleCancel} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<h3 id="centralizing-query-keys"><a class="anchor" href="#centralizing-query-keys">Centralizing Query Keys</a></h3>
<p>As I extracted the query hooks during the refactoring, query keys became scattered across multiple files. This made it difficult to determine which key to use for invalidation in a mutation's <code>onSuccess</code> callback.</p>
<p>I introduced <code>@lukemorales/query-key-factory</code> to manage the query keys centrally.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// queries/queryKeys.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> roomKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'rooms'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  list: { queryKey: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getRooms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> reservationKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reservations'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">date</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ queryKey: [date], </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(date) }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  my: { queryKey: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> remotes.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getMyReservations</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>This makes it possible to use them in the form <code>useSuspenseQueries({ queries: [roomKeys.list, reservationKeys.list(date)] })</code>, ensuring that a query key and its fetching function always travel together. I also extracted route paths into <code>PATHS</code> constants to eliminate hard-coded strings.</p>
<h2 id="what-was-the-exam-designed-to-assess"><a class="anchor" href="#what-was-the-exam-designed-to-assess">What Was the Exam Designed to Assess?</a></h2>
<p>After completing the refactoring, I took a step back and reflected. What was this mock exam trying to evaluate?</p>
<p>While reading other participants' retrospectives, I noticed an interesting common thread. Nearly every post included the sentence <strong>“Code is not read; it is predicted.”</strong> Our brains do not interpret code one line at a time. We read by making predictions based on patterns accumulated through experience, and cognitive load rises sharply when those predictions are violated.</p>
<p>From this perspective, the mock exam was assessing not merely coding ability, but the collaborative skill of <strong>“making code as predictable as possible for your colleagues.”</strong> (Perhaps the ability to read the minds of exam authors and coworkers is the true mark of a software engineer.)</p>
<p>Looking through other participants' retrospectives, I related to comments such as <strong>“Understanding code written by someone else is not easy,” and “Designing the interface first is important, but that approach can falter when you face a vast existing codebase.”</strong> I had a similar experience. When existing code already works, there is a temptation to rationalize its structure: “It already works, so why bother?” But the point of the mock exam was to move past that temptation and judge the code by <strong>“how quickly someone other than me can understand it, and whether I can assess and solve the problem using my own judgment.”</strong></p>
<h2 id="what-i-learned-from-refactoring"><a class="anchor" href="#what-i-learned-from-refactoring">What I Learned from Refactoring</a></h2>
<p><strong>The order of refactoring shapes the outcome.</strong> Working from the outside—the infrastructure—inward to the UI was a safe path that kept the process from becoming tangled midway through. Once the utilities and domain models were organized, splitting the components made each component's dependencies clear.</p>
<p><strong>The criterion for abstraction is the name.</strong> If the name of an extracted function or variable can explain its behavior, it is worth abstracting. If the name must remain vague, leaving the code inline may be the better choice.</p>
<p><strong>Where state lives is architecture.</strong> State that needs to move together should live in the same place. Using searchParams alone as the source of truth is structurally healthier than synchronizing <code>useState</code> with <code>searchParams</code>.</p>
<h2 id="closing-thoughts"><a class="anchor" href="#closing-thoughts">Closing Thoughts</a></h2>
<p>After finishing the task, I discussed it with two colleagues. Things I had not noticed while examining the code alone began to emerge as I worked through my thoughts in conversation. The moment someone asked “Why did you do it that way?” about a structural choice I had taken for granted, gaps in my reasoning that I had not recognized became visible.</p>
<p>It is true that AI is dramatically reducing the time required to write and review code. Even so, experiences like this are precisely why I believe code reviews and daily meetings remain important. AI can verify the consistency of code, but pointing out <strong>“Here is the perspective you missed”</strong> ultimately falls to colleagues who share the same context. Discovering what I could not see, then using that discovery to make the product more stable—is that not the essence of collaboration?</p>
<p>There is no single correct answer when writing code as part of solving a problem. Other participants who took the same mock exam followed different paths, each with their own rationale. What matters is being able to explain <strong>“why the code was written this way.”</strong> I encourage readers to look at their own code through the eyes of someone seeing it for the first time. That perspective may be the most powerful standard for determining code quality.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
            <category>refactoring</category>
        </item>
        <item>
            <title><![CDATA[The AI Frontend Engineer]]></title>
            <link>https://hooninedev.com/en/260302</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260302</guid>
            <pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to share my personal perspective on how engineers can grow and survive alongside AI. One of the articles that made the strongest impression on me as a junior was Hwidong Bae’s “Fr...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to share my personal perspective on <strong>how engineers can grow and survive alongside AI</strong>.</p>
<p>One of the articles that made the strongest impression on me as a junior was Hwidong Bae’s <a href="https://kr.linkedin.com/posts/hwidongbae_%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4-%EC%BB%A4%EB%A6%AC%EC%96%B4-%EB%A1%9C%EB%93%9C%EB%A7%B5-%EC%A3%BC%EB%8B%88%EC%96%B4%EB%A5%BC-%EC%9C%84%ED%95%9C-3%EA%B0%80%EC%A7%80-%EC%A0%84%EB%AC%B8%EC%84%B1-%ED%8A%B8%EB%9E%99-activity-7013888624140189696-XiIz" target="_blank" rel="noopener noreferrer">“Frontend Engineer Career Roadmap: Three Specialization Tracks for Juniors”</a>. It organizes frontend engineering careers into three tracks—<strong>web specialization (Software Engineer), product specialization (Product Engineer), and operations specialization (Full-Stack Engineer)</strong>—and goes on to identify “five foundational capabilities of exceptional engineers” and “three keys to becoming a senior.” At the time, the biggest question was which capabilities to build for each track. Yet less than two years after I read it, the question itself has changed completely.</p>
<p>When I talk with fellow engineers these days, their concerns feel noticeably different from those I had been hearing over the past several years.</p>
<ul>
<li>“Our company adopted AI, and if we give it a design mockup, it builds almost everything. It’s convenient, but...”</li>
<li>“The hiring market is brutally cold.”</li>
<li>“I’m afraid to merge AI-generated code as is, but reviewing every line defeats the efficiency gains. I’m not sure what to do.”</li>
</ul>
<p>I went through—and am still going through—a similar period. Just a year or two ago, I thought of AI as merely a useful assistant. Now, it is hard to imagine developing without it at all (I am also asking Claude to help with research as I write this). Think of this article as a sequel to Hwidong Bae’s piece: my attempt to describe how the landscape has changed since then and, from my own perspective, what additional capabilities frontend engineers need to develop within it.</p>
<p>Once again, I have tried to find and verify as many sources as possible. But because this field changes so quickly, some parts may already be outdated by the time this article is published. If you have a rebuttal or something worth discussing, please leave a comment anytime.</p>
<h2 id="doesnt-ai-do-everything-now"><a class="anchor" href="#doesnt-ai-do-everything-now">“Doesn’t AI Do Everything Now?”</a></h2>
<p>There is one question we need to address first. Is the statement “AI does everything now” actually true? How much of it is reality, and where does the fantasy begin?</p>
<p>In February 2025, <a href="https://x.com/karpathy/status/1886192184808149383" target="_blank" rel="noopener noreferrer">Andrej Karpathy</a>, an OpenAI co-founder and former director of AI at Tesla, posted the following on Twitter.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>There is a new kind of coding I call “vibe coding,” where you fully surrender to the vibes, embrace exponential growth, and even forget that the code itself exists.</p></div><div class="quote-original" lang="en"><p>There's a new kind of coding I call 'vibe coding', where you fully give in to the vibes, embrace exponentials, and forget that the code even exists.</p></div></blockquote>
<p>In short, <strong>vibe coding</strong> is “a way of coding where you hand the keyboard to AI and simply describe what you want in natural language.” There are no architecture documents, no boilerplate, and no hunting for semicolons. The code simply runs on vibes. In less than a year, the term became standard vocabulary in English-speaking developer communities.</p>
<p>Yet exactly one year later, in February 2026, the same Karpathy <a href="https://thenewstack.io/vibe-coding-is-passe/" target="_blank" rel="noopener noreferrer">took a step back</a>. He proposed replacing the term vibe coding with <strong>“agentic engineering.”</strong> The distinction is clear.</p>
<ul>
<li><strong>Vibe coding</strong>: Describing what you want and accepting the result</li>
<li><strong>Agentic engineering</strong>: Designing the system, specifying constraints, and using AI to accelerate an implementation you have already reasoned through in your head</li>
</ul>
<p>A year ago, the baseline assumption was, “Just tell it what to do and it will build everything.” Now, “the ability to design what to ask AI to do and how to ask it” has itself become an engineering capability. This shift is not merely one person’s tweet. Around the same time, Google engineer <a href="https://addyosmani.com/" target="_blank" rel="noopener noreferrer">Addy Osmani</a> published <a href="https://www.amazon.com/Beyond-Vibe-Coding-AI-Era-Developer/dp/B0F6S5425Y" target="_blank" rel="noopener noreferrer">Beyond Vibe Coding: From Coder to AI-Era Developer</a>, stating plainly: “AI is an assistant, not an autonomously trustworthy coder. You are the senior developer, and the LLM exists to accelerate your judgment.”</p>
<h3 id="the-tools-are-racing-ahead"><a class="anchor" href="#the-tools-are-racing-ahead">The Tools Are Racing Ahead</a></h3>
<p>The tools are also evolving rapidly in step with this shift. As of March 2026, the most frequently discussed coding tools include Cursor, Claude Code, GitHub Copilot, Windsurf, v0 by Vercel, Bolt.new, and Devin.</p>
<p>The evolution of v0 is especially symbolic. Vercel uses the phrase <a href="https://venturebeat.com/infrastructure/vercel-rebuilt-v0-to-tackle-the-90-problem-connecting-ai-generated-code-to" target="_blank" rel="noopener noreferrer">“the 90% problem”</a>, meaning that 90% of real-world development takes place within existing codebases and infrastructure. At first, v0 only needed to excel at greenfield prototypes. Now it can import a GitHub repository directly, work within it, enforce a design system, and automatically pull in deployment environment variables. In effect, toolmakers are directly answering senior engineers who ask, “Isn’t AI only good at making toy demos?”</p>
<p>Big Tech codebases illustrate this change most clearly.</p>
<p>Google’s Sundar Pichai <a href="https://fortune.com/2024/10/30/googles-code-ai-sundar-pichai/" target="_blank" rel="noopener noreferrer">announced during the Q3 2024 earnings call that “more than 25% of new code is generated by AI and then reviewed and accepted by engineers”</a>, and said in April 2025 that the figure had risen above 30%. Microsoft’s Satya Nadella <a href="https://www.cnbc.com/2025/04/29/satya-nadella-says-as-much-as-30percent-of-microsoft-code-is-written-by-ai.html" target="_blank" rel="noopener noreferrer">revealed at LlamaCon in April 2025 that “as much as 30% of our code is written by AI”</a>. Meta’s internal target has risen as high as “by the first half of 2026, 65% of engineers will generate more than 75% of their commits with AI.”</p>
<p>The trend is no different in Korea. <a href="https://toss.tech/article/toss-frontend-ai-docs" target="_blank" rel="noopener noreferrer">Toss</a> built an AI-powered documentation system to improve developer experience so developers no longer have to search for documentation, and went a step further by exploring topics such as <a href="https://toss.tech/article/removing_designers_in_ai_era" target="_blank" rel="noopener noreferrer">“What Happened When We Removed Designers in the AI Era”</a>. Every Tuesday, Karrot shares team experiments through <a href="https://medium.com/daangn" target="_blank" rel="noopener noreferrer">AI Show &#x26; Tell</a>, and has begun using the <a href="https://about.daangn.com/blog/archive/%EB%8B%B9%EA%B7%BC-%ED%95%B4%EC%BB%A4%ED%86%A4-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4-%EC%B1%84%EC%9A%A9/" target="_blank" rel="noopener noreferrer">recruiting slogan</a> “Beyond Engineer, Become a Builder.” Woowa Brothers has published articles such as <a href="https://techblog.woowahan.com/22828/" target="_blank" rel="noopener noreferrer">“In an Era When AI Writes Code, Do You Still Want to Become a Developer?”</a>, arguing that “the essence of being a developer lies not in code, but in the ability to define and solve problems.”</p>
<h3 id="but-the-numbers-tell-a-slightly-different-story"><a class="anchor" href="#but-the-numbers-tell-a-slightly-different-story">But the Numbers Tell a Slightly Different Story</a></h3>
<p>At this point, it is easy to conclude, “So now you can just tell AI what to do and everything works.” But the actual data tells a somewhat different story.</p>
<p>First, consider the figures from the <a href="https://survey.stackoverflow.co/2025/ai" target="_blank" rel="noopener noreferrer"><strong>2025 Stack Overflow Developer Survey</strong></a>, a comprehensive analysis of the state of software development.</p>
<ul>
<li>84% of developers said they use or plan to use AI tools, up from 76% in 2024.</li>
<li>Among professional developers, 51% use AI tools every day.</li>
<li>Yet <strong>positive sentiment toward AI tools actually declined</strong>. After exceeding 70% in both 2023 and 2024, it fell to 60% in 2025.</li>
<li>Senior developers with more than ten years of experience have the lowest trust in AI output.</li>
</ul>
<p>In short: <strong>“Everyone uses it, but the longer they do, the less they trust it.”</strong></p>
<p>A 2025 experiment conducted by the nonprofit research institute <a href="https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/" target="_blank" rel="noopener noreferrer"><strong>METR</strong></a> revealed the gap between perception and reality even more dramatically. It was a controlled experiment in which 16 experienced open-source developers—averaging five years of experience and 1,500 commits—completed 246 tasks and were randomly assigned whether to use AI. The results were as follows.</p>
<ul>
<li>Before starting, the developers predicted that AI would make them 24% faster.</li>
<li>Immediately after finishing, they still estimated that they had been about 20% faster.</li>
<li>Actual measurements, however, showed that they were <strong>19% slower</strong>.</li>
</ul>
<p>The researchers’ explanation is revealing. The acceptance rate for AI-generated code was below 44%; rejected code still required time to review and test; and even accepted code demanded substantial review and revision. This gap—the illusion of feeling faster despite actually slowing down—is one reason senior developers are becoming increasingly skeptical of AI.</p>
<p>Nor is the quality of AI-generated code itself especially polished. Consider <a href="https://www.veracode.com/blog/genai-code-security-report/" target="_blank" rel="noopener noreferrer"><strong>Veracode’s</strong></a> experiment in which more than 100 AI models were asked to write code.</p>
<ul>
<li><strong>45% of AI-generated code contained OWASP Top 10 security vulnerabilities</strong>.</li>
<li>The failure rate for <strong>XSS (cross-site scripting) protection was 86%</strong>.</li>
<li>The failure rate for log injection protection was 88%.</li>
<li>Another study reported that the vulnerability density of AI code was <strong>2.7 times higher</strong> than that of human-written code.</li>
</ul>
<p>The 86% failure rate for XSS, in particular, deserves serious attention from frontend engineers. It shows exactly what it means to merge an AI-generated form input unchanged. (Developers with frontend security-audit experience feel uneasy and concerned even when they write <code>dangerouslySetInnerHTML</code> themselves; it feels even more frightening when AI quietly slips it in.)</p>
<p>The quality signals are similar elsewhere. <a href="https://www.gitclear.com/ai_assistant_code_quality_2025_research" target="_blank" rel="noopener noreferrer"><strong>GitClear</strong></a> analyzed 211 million lines of code changes made between 2020 and 2024 and found the following.</p>
<ul>
<li>Code churn—the share of code reverted within two weeks of being written: 5.5% in 2020 → <strong>7.9%</strong> in 2024</li>
<li>Share of changes devoted to refactoring: 25% in 2021 → <strong>below 10%</strong> in 2024</li>
<li>Copy-and-paste (clone) rate: 8.3% in 2021 → <strong>12.3%</strong> in 2024 (and an astonishing fourfold increase in 2025)</li>
</ul>
<p>The interpretation is not difficult. Our ability to produce code quickly has increased, while our ability to produce code worth refining has declined. <a href="https://www.softwareseni.com/ai-generated-code-security-risks-why-vulnerabilities-increase-2-74x-and-how-to-prevent-them/" target="_blank" rel="noopener noreferrer">Apiiro’s data</a> from Fortune 50 companies is even more striking. AI-assisted developers produce three to four times as many commits as their peers, but also ten times as many security findings. Privilege-escalation paths rose by 322%, while architectural design flaws surged by 153%.</p>
<h2 id="what-ai-has-replacedand-what-it-has-not"><a class="anchor" href="#what-ai-has-replacedand-what-it-has-not">What AI Has Replaced—and What It Has Not</a></h2>
<p>The tools are racing ahead, yet the numbers are nuanced. So what exactly has AI replaced, and what has it not yet replaced? We need a clear distinction to understand where to invest our time.</p>
<p>What AI has replaced is a large share of developers’ manual typing. We spend less time writing boilerplate and repetitive code; given a design mockup, a screen that follows the conventions can appear within minutes; and the time spent searching for syntax and APIs, as well as the learning curve, has fallen sharply. In short, AI has <strong>flattened the speed of production</strong>.</p>
<p>But it has not yet replaced the domain of “judgment.” (More precisely, it has “not yet met our expectations.” People differ in their ability to use AI, but this discussion is based on the average user experience.)</p>
<p>The first sticking point is <strong>translating requirements into specifications</strong>. Turning ambiguous business requirements into precise edge cases and state machines still requires deeper human involvement. The same is true of <strong>understanding system-wide impact</strong>. Questions such as how a component affects the bundle, whether a dependency can be tree-shaken, or how a data-fetching pattern affects the <a href="https://web.dev/articles/vitals" target="_blank" rel="noopener noreferrer">Core Web Vitals</a> metric <a href="https://web.dev/articles/inp" target="_blank" rel="noopener noreferrer">INP (Interaction to Next Paint)</a> may receive plausible answers from AI, but a human still needs to examine them before we can feel confident.</p>
<p>We also cannot omit <strong>security and risk assessment</strong>, as illustrated by the 45% OWASP vulnerability rate above. The same applies to <strong>maintaining the design system and consistency</strong>—checking whether a new component aligns with the existing system’s tokens, accessibility rules, and interaction patterns—and to <strong>understanding customer and market context</strong>, such as why a feature is needed and where it belongs in the user flow.</p>
<p>Finally, borrowing a phrase from <a href="https://yceffort.kr/2026/02/frontend-engineering-in-ai-era" target="_blank" rel="noopener noreferrer">yceffort’s article</a>, <strong>managing cognitive debt</strong>—the “gap between a system’s complexity and the degree to which the team understands that system”—has actually become a faster-growing concern since AI adoption. Closing that gap remains a human responsibility.</p>
<blockquote>
<p>Developers are not disappearing; the form of developers’ work is changing. The bottleneck has shifted from “the speed of building” to “the speed of deciding.”</p>
</blockquote>
<p>In the same vein, Toss’s <a href="https://toss.tech/article/will-ai-replace-developers" target="_blank" rel="noopener noreferrer">“Will AI Replace Developers?”</a> offers a weightier diagnosis. Its central point is this: AI is not replacing the entire workforce; it is removing the apprenticeship ladder. Ten or twenty years from now, when today’s senior engineers retire, there may be too few people in the next generation capable of designing complex systems. This is not merely a question of “what will our company do about hiring next year?” It is a delayed-action bomb for the entire industry. (I think it is an exceptionally well-written article for a time when so many of us are wrestling with these questions.)</p>
<p>The “first version that works” produced by AI is 70%. The remaining 30%—getting it to “a version safe to serve to real users”—belongs to humans. And the ability to fill that 30% does not appear overnight. This is the essence of the apprenticeship-ladder problem. If the time spent “getting your hands dirty” writing boilerplate and simple components disappears, so do the people who will eventually be able to fill that 30%.</p>
<p>Hwidong Bae’s original article named <strong>writing good code, maximizing present value (balancing fast delivery with long-term maintainability), making data-driven decisions, helping colleagues make effective decisions, and learning continuously</strong> as the “five foundational capabilities of exceptional engineers.” All five remain relevant in the AI era, but the last is in the most precarious position. Learning itself has not disappeared; its subject has changed. Where we once learned “how do I use this tool?”, we now need to spend time learning “how does this entire system work?” More troubling still is the problem <a href="https://evan-moon.github.io/2026/04/18/developers-who-stopped-growing-in-ai-era/" target="_blank" rel="noopener noreferrer">Evan Moon identifies</a>: “the moment AI takes over code writing, the brain’s cognitive load drops dramatically.” Reduced cognitive load sounds appealing, but it is dangerous because that load was the very material from which learning emerged. <strong>The more comfortable it gets, the less you grow.</strong></p>
<p>This naturally raises a question. Do the three tracks in Hwidong Bae’s article—web specialization, product specialization, and operations specialization—no longer matter?</p>
<p>I see it differently. The tracks themselves remain valid. It is more accurate to say that each has evolved by one stage for the AI era. Let us examine how the landscape has changed for each one.</p>
<h2 id="from-producer-to-verifier"><a class="anchor" href="#from-producer-to-verifier">From Producer to “Verifier”</a></h2>
<p>In Hwidong Bae’s original article, the web specialization track was grouped under the title <strong>Software Engineer</strong>. Its core capabilities were “a deep understanding and practical command of the internet, web browsers, and HTML/CSS/JS,” familiarity with the trade-offs of web ecosystem tools, troubleshooting experience, and sensitivity to emerging technologies. The suggested senior paths were <strong>engineer at a web tooling company, frontend educator, or tech lead in a complex product organization</strong>. In short, these were “people who dig deeply into how browsers and HTML/CSS/JS work,” and until a year or two ago, their greatest advantage was that “they could write correct code more accurately than anyone else.”</p>
<p>How has their value changed in the AI era? In terms of coding speed alone, AI has caught up. Yet <strong>the ability to accurately evaluate AI-generated code</strong> has become something they almost uniquely possess.</p>
<ul>
<li>Non-developer using AI: It implemented my requirements, and it works correctly.</li>
<li>Developer using AI: It works, but this dependency could cause certain problems, and improving this pattern in this way would better fit our conventions. Let’s examine the related areas again.</li>
</ul>
<p>The Veracode study discussed earlier found failure rates of 86% for XSS and 88% for log injection. Experts like us are the people capable of finding and fixing those failures. They naturally evolve into senior quality-assurance roles for AI output.</p>
<p>There is another development: an entirely new topic has entered the expert domain—<strong>Generative UI</strong> and <strong>AI interface design</strong>. Examples include chat UIs that render LLM responses as they stream, abort controls that let users stop generation midway, progressive rendering of Markdown and code blocks, UX that displays tool-call results inline, and assistant integrations using the <a href="https://sdk.vercel.ai/" target="_blank" rel="noopener noreferrer">Vercel AI SDK</a> or <a href="https://modelcontextprotocol.io/" target="_blank" rel="noopener noreferrer">MCP (Model Context Protocol)</a>. Demand is exploding for people who “understand precisely how the web works while also understanding the behavioral characteristics of LLMs and knowing how to apply them.”</p>
<h2 id="the-natural-evolution-into-a-product-engineer"><a class="anchor" href="#the-natural-evolution-into-a-product-engineer">The Natural Evolution into a Product Engineer</a></h2>
<p>The product specialization track has benefited the most. People who understand markets and customers well and communicate frequently with external stakeholders gained a far more powerful tool the moment AI entered the picture. Another hallmark of this track was that its suggested senior paths included expansion into other roles, such as <strong>growth engineer or consultant, or a transition to PM, PO, or CPO</strong>.</p>
<p>An interesting change is that the track’s name has begun to establish itself as a global standard. The original article already called it “Product Engineer,” but when I first read it, the term still felt somewhat unfamiliar. A year later, it has become established enough that <a href="https://leerob.com/product-engineers" target="_blank" rel="noopener noreferrer">Vercel renamed every “Fullstack Engineer” role in its job descriptions to “Product Engineer”</a>.</p>
<p>Lee Robinson identifies three core qualities of a Product Engineer.</p>
<ul>
<li><strong>Iteration-mindedness</strong>: Moving quickly through the deploy → feedback → adjustment cycle.</li>
<li><strong>Customer centricity</strong>: Improving the product by speaking directly with customers.</li>
<li><strong>Pragmatism</strong>: “Every technology choice is merely a means to an end.” Tools that do not advance the product goal are discarded without hesitation.</li>
</ul>
<p>There is one trap here: it is dangerous when product-focused engineers are perceived merely as “people who build quickly.” With the arrival of AI, that danger has grown. “Shipping features fast” is now something people in virtually any role can do with AI tools. A Product Engineer’s differentiator is “the ability to define the customer’s problem accurately and validate it quickly with the smallest possible solution,” not “having fast hands.”</p>
<p>Within this trend, <strong>Design Engineer</strong> has begun to rise into a formal role. Vercel is hiring <a href="https://cjroth.com/blog/2026-02-18-building-an-elite-engineering-culture" target="_blank" rel="noopener noreferrer">design engineers as a formal career track with compensation above $200K</a>, and Linear and Stripe are moving in similar directions. The role eliminates the handoff between frontend and design itself. Because AI can draw quickly, the combined ability to decide “what to draw” and determine “whether the result fits a consistent design system” has become even scarcer.</p>
<h2 id="ai-orchestrator"><a class="anchor" href="#ai-orchestrator">AI Orchestrator</a></h2>
<p>The operations specialization track is changing most dramatically. Hwidong Bae’s original article classified this track as <strong>Full-Stack Engineer</strong>, defining it as “someone deeply interested in project structure, integration, testing, and deployment; someone who can handle simple APIs and infrastructure directly, fill gaps in the organization, and improve processes.” Over the past year or two, <strong>the role of operating AI agents themselves</strong> has been added on top, rapidly expanding the track’s scope.</p>
<p>In a review of <a href="https://beyond.addy.ie/2026-trends/" target="_blank" rel="noopener noreferrer">2026 trends</a>, <strong>“Orchestrating Coding Agents”</strong> was identified as a central concept. It means going beyond assigning work to one AI and instead designing and operating a system in which multiple AI agents collaborate simultaneously. In the same vein, the <a href="https://github.com/addyosmani/agent-skills" target="_blank" rel="noopener noreferrer">“agent-skills”</a> framework was proposed, along with the idea of encoding professional workflows, quality gates, and industry best practices directly into agent behavior.</p>
<p>After reviewing the related material, these are the new keywords I believe engineers in the operations track need to work with.</p>
<ul>
<li><strong>MCP (Model Context Protocol)</strong>: Anthropic’s proposed standard for connecting LLMs with external tools</li>
<li><strong>AI governance</strong>: Managing who can use AI, with what context, and whether secrets can leak</li>
<li><strong>Agent evaluation</strong>: Pipelines that automatically score the output produced by agents</li>
<li><strong>AI gates</strong>: Automated security and quality verification before PR merges, plus labeling for AI-generated code</li>
</ul>
<p>The original article named roles such as <strong>platform team engineer in a large organization, tech lead, agile coach, technical program manager (TPM), and CTO</strong> as senior paths for the operations track. Those paths remain valid, but new roles such as <strong>“AI development infrastructure lead”</strong> and <strong>“developer productivity (DevProd) engineer”</strong> have now been added.</p>
<p>While the three tracks are each evolving, there are also capabilities that have become more important across all of them. I originally wanted to think in terms of five years from now, but at today’s pace of progress, even a year feels like too large a unit. So I will narrow the horizon to “next year” and identify the capabilities I expect to become more important.</p>
<h2 id="five-capabilities"><a class="anchor" href="#five-capabilities">Five Capabilities</a></h2>
<p><strong>The first is the ability to write specifications.</strong> In the AI era, the “starting point of coding” is not the keyboard but the <strong>specification</strong>. The ability to state precisely what AI should do has become more important than the code itself. A specification here does not mean a grand RFC document. It can be <strong>tests</strong> that express the expected behavior of business logic in code, <strong>Storybook stories</strong> that define a UI component’s scenarios and visual contract, or <strong>type definitions</strong> that specify the contract for data flow. Ultimately, the work is about establishing in advance the criteria that will automatically verify AI-generated output. If AI coding proceeds without those criteria, problems accumulate.</p>
<p><strong>The second is verification and judgment.</strong> AI confidently produces code that looks plausible but is wrong. That is why I believe “the ability to review AI-generated code quickly and accurately” is itself a core capability. It means determining whether security headers, input sanitization, or CSRF tokens are missing; whether accessibility—ARIA, keyboard navigation, and focus traps—remains intact; and whether there are performance issues involving rendering cost, memory, or bundle size. Throwing AI slop into a PR without review is a dereliction of an engineer’s duty. A human still clicks the merge button, and that responsibility cannot be shifted onto AI. Senior developers may have the lowest trust in AI in the Stack Overflow survey precisely because they have the eye to catch these details.</p>
<p><strong>The third is system understanding and architectural thinking.</strong> AI handles one file at a time well and has strong awareness of flows and relationships. AI fixes symptoms quickly, but highly capable developers find root causes. One way to build this capability is through deliberate practices such as Architecture Retrospectives. As the rate of code change accelerates, cognitive debt will accumulate quickly unless the team deliberately raises its understanding of the system as well.</p>
<p><strong>The fourth is AI orchestration.</strong> The ability to work with AI is separating into its own skill set. This is no longer simply about writing good prompts. It now encompasses the ability to break work into small tickets, choose which model to use for a given task, design agent evaluation and verification pipelines, and define recovery strategies—including rollback—when an agent fails. <a href="https://sourcegraph.com/blog/revenge-of-the-junior-developer" target="_blank" rel="noopener noreferrer">Steve Yegge</a> describes this progression as <strong>six waves (traditional → completions → chat → coding agents → agent clusters → agent fleets)</strong>.</p>
<p><strong>The fifth is Context Engineering.</strong> Promoted since mid-2025 by <a href="https://www.faros.ai/blog/context-engineering-for-developers" target="_blank" rel="noopener noreferrer">Karpathy and Shopify CEO Tobi Lütke</a>, it can be summarized as “the ability to design what context to show AI, in what form, and how much of it.” In practice, it takes forms such as maintaining <strong>CLAUDE.md / rules files</strong> that place project conventions, architectural principles, and prohibitions within AI’s reach; <strong>deliberately reducing context</strong> by selecting only relevant modules instead of loading every file; <strong>explicitly separating phases</strong>—planning → implementation → verification—into different sessions to prevent context contamination; and supplying <strong>external context through MCP</strong>, connecting design systems, API schemas, monitoring data, and other resources through standard interfaces. <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic’s official documentation</a> calls this “the new prompt engineering” and states plainly that a single prompt can never contain a system’s architectural knowledge, patterns, and tribal wisdom. Put differently, “designing an environment where AI consistently receives good context” has become far more important than “writing one good prompt.”</p>
<p>If you have read this far, a natural question follows: how, specifically, should we study? I use roughly four approaches.</p>
<h2 id="how-to-learn"><a class="anchor" href="#how-to-learn">How to Learn</a></h2>
<p>The “continuous learning” item from the original article remains valid, but we need to allocate our learning time differently.</p>
<p>There are areas where we once spent a great deal of time but can now spend less. By contrast, there are complex areas that we may have avoided because they were difficult or time-consuming. The latter include writing test specifications, using performance measurement tools (<a href="https://developer.chrome.com/docs/lighthouse" target="_blank" rel="noopener noreferrer">Lighthouse</a>, <a href="https://www.webpagetest.org/" target="_blank" rel="noopener noreferrer">WebPageTest</a>, Chrome DevTools Performance), accessibility (<a href="https://www.w3.org/WAI/standards-guidelines/wcag/" target="_blank" rel="noopener noreferrer">WCAG</a>), and security—especially the <a href="https://owasp.org/www-project-top-ten/" target="_blank" rel="noopener noreferrer">OWASP Top 10</a>. There are also entirely new areas to learn, including the Vercel AI SDK, LangChain.js, MCP, streaming UI patterns, and agent evaluation pipelines. <strong>It is important to recognize which capabilities you need and allocate your time accordingly.</strong></p>
<p>AI-generated code tends to grow large. It can produce hundreds of lines in a minute. Unless we deliberately manage PR size and merge cadence, code review itself breaks down. Within companies, average PR size rose 18%, incidents per PR rose 24%, and the change failure rate rose 30% after AI adoption. Viewed alongside the data discussed earlier, this shows why it is important to subdivide work: writing a large change and merging it all at once makes it difficult to understand the flow and preserve intent.</p>
<p>There is a practice directly related to the reduced cognitive-load problem identified in Evan Moon’s article: it is worth setting aside one or two hours a day to write code without AI. Drawing an architecture by hand or reading through code in an unfamiliar area one line at a time are examples. (I also write code without AI during the drowsy period after lunch each day. It is time I use to keep from drifting too far from old, familiar habits.)</p>
<p>This is not merely about “avoiding forgetting the old way.” Your own depth does not grow during the time AI does the work for you. Capabilities such as verification, judgment, and system understanding are functions of the time you spend confronting problems directly.</p>
<h2 id="so-where-does-that-leave-us"><a class="anchor" href="#so-where-does-that-leave-us">So, Where Does That Leave Us?</a></h2>
<p>I have written at length, but the image of a frontend engineer who survives the AI era is not actually so different from the conclusion of the original article. It identified three qualities of a good senior engineer.</p>
<ul>
<li>They strive to <strong>stay grounded in the fundamentals</strong>. (Continuously maintaining and strengthening the five foundational capabilities.)</li>
<li>Even without being the designated leader, they exert natural influence through exemplary behavior.</li>
<li>They do not settle for completing the task in front of them; they examine the broader context and create greater impact.</li>
</ul>
<p>Applied to the AI era, that becomes the following.</p>
<ul>
<li>Stay grounded in the <strong>fundamentals</strong> beyond AI-generated code: the web, systems, and the domain.</li>
<li>Set the direction yourself, not AI. Even when you are not the designated owner, decide “where we should go.”</li>
<li>Use AI not merely as a personal productivity tool, but to remove bottlenecks in the team and the system.</li>
</ul>
<p>Looking at the writing of Andrej Karpathy, an authority on OpenAI, the core of the <strong>“agentic engineering”</strong> he now emphasizes is ultimately the same: design the system, specify the constraints, and use AI to accelerate an implementation you have already reasoned through in your head. The tools may change, but the directional controls remain in human hands.</p>
<p>The original article’s closing message was likewise that the person who becomes a senior is someone who “does not settle for completing the assigned work well, but examines the surrounding context and creates greater impact.” In the AI era, only the definition of that “impact” has changed. One person merges a screen AI built in an hour because “it works.” Another spends thirty more minutes examining how reasonable it is in terms of accessibility, security, performance, and system consistency. A year later, it is the latter who will be recognized as a senior. The people who survive will be those who stand on the 30% side of the boundary between 70% (functionality) and 30% (application and effective use).</p>
<p>I hope the frontend engineers reading this leave with their own answer to the question, “What should I study next?” No one knows the definitive answer, but I am quite confident that the more AI writes our code, the more the people who can see “what lies beyond the code” will survive. I will close with the hope that, a year from now, I can write again about how this landscape has changed. <strong>(If this article feels painfully obvious or outdated a year from now, perhaps that will mean we responded well enough.)</strong></p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>career</category>
            <category>AI</category>
        </item>
        <item>
            <title><![CDATA[Abstraction]]></title>
            <link>https://hooninedev.com/en/260201</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260201</guid>
            <pubDate>Sun, 01 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about abstraction in programming and how to write good code from the perspective of abstraction. As a frontend developer, I have wrestled countless times with questions li...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about abstraction in programming and how to write good code from the perspective of abstraction.</p>
<p>As a frontend developer, I have wrestled countless times with questions like, “How much of this logic should I separate?” and “At what level should I break up this component?” At first, I thought abstraction simply meant extracting what different pieces had in common: turning repeated code into a function, or identifying the commonalities among similar components and combining them into one. But after watching code built this way turn into a monster that became harder to touch over time on several occasions, I began to rethink what abstraction really is.</p>
<p>In this article, I want to organize the thoughts I have developed about the essence of abstraction and how we can use it in frontend development to create good code.</p>
<h2 id="abstract-and-abstraction"><a class="anchor" href="#abstract-and-abstraction">Abstract and Abstraction</a></h2>
<p>Before getting into the main discussion, I want to clarify exactly what the words “abstract” and “abstraction” mean in programming. They may look similar, but they are quite different in nature.</p>
<p><strong>Abstract</strong> is a state and a property. When we say, “This is abstract,” we mean that concrete details have been omitted, leaving <strong>only the essential concept</strong>. Classes or methods marked with the <code>abstract</code> keyword in Java or TypeScript carry precisely this meaning. They are incomplete blueprints that define only the essential form, before the concrete implementation has been filled in.</p>
<p><strong>Abstraction</strong> is a process and an act. It is the process of simplifying something complex by retaining only its essential characteristics and removing unnecessary details. Crucially, abstraction is not about “roughly lumping things together”; it is <strong>the act of defining roles precisely at each level</strong>.</p>
<p>In everyday language, “abstract” often carries the nuance of “vague.” In programming, however, abstraction is the exact opposite. Its purpose is not to make things vague, but to create a new level of meaning that can be absolutely precise. The essence of abstraction is preserving information relevant to a given context and forgetting what is irrelevant.</p>
<p>Ultimately, the distinction between abstract and abstraction can be understood like this: <strong>the abstract is “a state in which only the essence remains,” while abstraction is “the process of leaving only the essence.”</strong> What we do when designing code is precisely this process of abstraction: retaining only the essential interface from a complex implementation and hiding the rest.</p>
<p>Then why do we need abstraction in programming?</p>
<h2 id="why-do-we-need-abstraction"><a class="anchor" href="#why-do-we-need-abstraction">Why Do We Need Abstraction?</a></h2>
<p>The fundamental reason abstraction is necessary in programming is surprisingly simple: <strong>to build more complex things</strong>. When we try to build something more complex, there are too many complex elements to remember and manage all at once. So we group those elements and turn them into simplified, abstract concepts.</p>
<p>Consider React, which frontend developers use every day. Rendering a single component internally involves complex processes such as creating the Virtual DOM, reconciliation, and manipulating the actual DOM. Yet we can write JSX without worrying about any of that, because React abstracts those complex processes away for us.</p>
<p>Look at the code below. When using the UserProfile component, we can build and work with the UI perfectly well without knowing about the complex processes happening internally, such as VDOM creation and diffing.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">UserProfile</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"jihoon"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>Configuring Webpack directly used to be part of a frontend developer’s daily life, but frameworks such as Next.js and Vite have since abstracted away bundler configuration. As a result, we can develop applications without knowing the bundler’s inner workings and use that time to <strong>focus on higher-level problems such as business logic and user experience</strong>. (This is why I believe the concept of abstraction is extremely important to the role of a frontend developer.)</p>
<p>This, ultimately, is the core value of abstraction: hiding complexity so that it appears simple and allowing each of us to focus only on our own domain. Thanks to it, we can build increasingly large and complex software without having to understand everything ourselves.</p>
<p>If abstraction is so beneficial, then is more abstraction always better? Let us consider what purpose abstraction should serve.</p>
<h2 id="reducing-context"><a class="anchor" href="#reducing-context">Reducing Context</a></h2>
<p>Many developers understand abstraction as “extracting the common parts.” That is not wrong, but it is only one technique for performing abstraction; it does not explain abstraction’s essence.</p>
<p>To me, the essence of abstraction is <strong>“reducing the context a reader needs to know to an appropriate level.”</strong></p>
<p>Let us look at a simple example.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "pending"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> total </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> orders</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"a"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"pending"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, amount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">10000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"b"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, amount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  { id: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"c"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, amount: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">8000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> orders.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (orders[i].status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    total </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> orders[i].amount;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>A developer reading this code has to understand the loop’s initialization, condition, and increment; follow element access by index; inspect the conditional branch; and keep track of how the external accumulator is updated. What the code is actually trying to do can be expressed in one sentence—<strong>“calculate the total of the completed orders”</strong>—yet understanding that sentence requires holding four different contexts in mind at once.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> total</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> orders</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> order.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "completed"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sum</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">order</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> sum </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> order.amount, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>Thanks to the abstractions <code>filter</code> and <code>reduce</code>, the developer only needs to follow two intentions: “select only completed orders” and “accumulate their amounts.” The context of index management and the declaration and updating of an accumulator disappears from the surface of the code.</p>
<p>We can take this one step further.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> total</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sumCompletedOrders</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(orders);</span></span></code></pre></figure>
<p>Now the reader does not even need to know that the calculation is performed by traversing an array. All that remains is the business intent: “calculate the total amount of completed orders.” The code lets us focus not on <strong>how</strong> the calculation is performed, but on <strong>what</strong> is being calculated.</p>
<p>From this perspective, we can see that the React code we write every day is itself a composition of countless abstractions.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { css } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "@emotion/css"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { format } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "date-fns"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TodayHeader</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> now</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// Date 객체 생성이라는 복잡한 과정을 추상화</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> formatted</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> format</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(now, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"yyyy-MM-dd"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 날짜 포맷팅 로직을 추상화</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h1</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">css</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">        font-size: 1.8rem;</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">      `</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    ></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* CSS-in-JS 처리 과정을 추상화 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      Today is {formatted}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// React.createElement를 추상화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 그리고 위 모든 것을 다시 추상화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">TodayHeader</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span></code></pre></figure>
<p>What if all the internal code of <code>emotion</code>, <code>date-fns</code>, and <code>react</code> were expanded directly inside this component file? It would be difficult to know where to begin reading or distinguish the business logic from the library code. Because abstraction hides the context of each domain at an appropriate level, we can focus solely on the essential purpose: “show today’s date.”</p>
<p>Then how should we approach abstraction when designing actual code?</p>
<h2 id="what-it-means-for-a-level-of-abstraction-to-be-high-or-low"><a class="anchor" href="#what-it-means-for-a-level-of-abstraction-to-be-high-or-low">What It Means for a Level of Abstraction to Be High or Low</a></h2>
<p>Any discussion of abstraction must include the concept of the <strong>Level of Abstraction</strong>. What exactly does it mean for code’s level of abstraction to be “high” or “low”?</p>
<p><strong>Code at a low level of abstraction</strong> is close to the concrete procedures performed by the computer: parsing strings directly, iterating through arrays by index, manipulating bytes. It openly exposes <strong>how</strong> it works.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 낮은 코드</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'/api/users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> users</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> response.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> filteredUsers</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> user.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'active'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">filteredUsers.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">forEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> element</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> document.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getElementById</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`user-${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">id</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (element) element.style.display </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'block'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p><strong>Code at a high level of abstraction</strong> is expressed in the language of the business domain or problem space. Examples include <code>processPayment(order)</code>, <code>sendNotification(user, message)</code>, and <code>validateUserInput(formData)</code>. High-level code reveals <strong>what</strong> it does while hiding how it does it.</p>
<p>In <strong>Clean Code</strong>, Robert C. Martin summarized this idea as the principle of <strong>“One Level of Abstraction per Function.”</strong> When high-level and low-level code are mixed within one function, the reader must decide at every line, “Is this core logic, or is it an implementation detail?”</p>
<p>The problem becomes clear in actual code.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 뒤섞인 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">password</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 비즈니스 규칙 검증</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  validateUserInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ensureEmailNotDuplicated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(email);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ❌ 낮은 수준: 비밀번호 해싱 직접 구현</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> encoder</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TextEncoder</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> encoder.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">encode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(password);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashBuffer</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> crypto.subtle.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">digest</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"SHA-256"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, data);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashedPassword</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">from</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Uint8Array</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(hashBuffer))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">b</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> b.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">16</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">padStart</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"0"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">""</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 사용자 저장</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> userRepository.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">save</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password: hashedPassword });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ❌ 낮은 수준: 이메일 전송 직접 구현</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> verifyToken</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> crypto.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">randomBytes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">32</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"hex"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> db.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">execute</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">    "INSERT INTO email_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    [user.id, verifyToken, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Date.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 86_400_000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> transporter.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sendMail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    from: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"noreply@example.com"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    to: user.email,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subject: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"Welcome! Please verify your email"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    html: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`&#x3C;a href="/verify?token=${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">verifyToken</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}">Verify your account&#x3C;/a>`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ✅ 높은 수준: 환영 이메일 발송</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendWelcomeEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The reader of this function begins by following the high-level, business-rule context of the “user registration flow,” only to be abruptly dragged down into the low-level contexts of hash buffer manipulation, SQL queries, and email template strings. Then the code jumps back up to the high-level <code>sendWelcomeEmail</code>. When the level of abstraction keeps rising and falling like this, the reader’s mind has to rise and fall with it.</p>
<p>If we rewrite the same function so that its level of abstraction is consistent, it looks like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 추상화 수준이 일관된 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">password</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  validateUserInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password });</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ensureEmailNotDuplicated</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(email);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> hashedPassword</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hashPassword</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(password);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> userRepository.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">save</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ name, email, password: hashedPassword });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendVerificationEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> sendWelcomeEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(user);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Every statement speaks at the same level of abstraction. How email delivery is implemented and which algorithm password hashing uses are the responsibilities of their respective lower-level functions. The reader of this function only needs to focus on a single context: the overall user registration flow.</p>
<p>Martin also called this <strong>“The Stepdown Rule.”</strong> When code is read from top to bottom, it should resemble a newspaper article: the big picture appears at the top, and the details emerge as the reader moves downward.</p>
<p>Kent Beck presented the same principle as the <strong>Composed Method pattern</strong> in <strong>Smalltalk Best Practice Patterns</strong>. A method should consist only of operations at the same level of abstraction, and each step should be expressed as a one-line method call.</p>
<p>All of this ultimately leads to one conclusion: <strong>a function should speak at only one level of abstraction.</strong> Following this principle alone makes a noticeable difference in readability.</p>
<p>Then which direction should abstraction take? Should we begin with the concrete or with the abstract?</p>
<h2 id="think-in-terms-of-assembling-parts-not-extracting-commonalities"><a class="anchor" href="#think-in-terms-of-assembling-parts-not-extracting-commonalities">Think in Terms of Assembling Parts, Not Extracting Commonalities</a></h2>
<p>OOP commonly offers the guideline, “Extract the commonalities among concrete things to define something abstract.” The approach itself is not wrong, but I believe adhering to it too rigidly risks producing a design trapped by the current requirements.</p>
<p>Consider an example. Suppose the requirements call for buttons A, B, and C. All three are blue and rounded; the only difference is their label text. A design based solely on extracting their commonalities might look like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> BlueRoundButton</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">label</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">label</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"blue round"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{label}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>This perfectly satisfies the current requirements. But a few days later, the product manager says:</p>
<blockquote>
<p>“Please make it possible to change the color of button B.”</p>
</blockquote>
<p>At that point, even the name <code>BlueRoundButton</code> becomes awkward. We could add a color prop, but the design was vulnerable to change from the outset because it began with the concrete commonality of “a blue rounded button.” (And this is a mild example. In reality, countless requests arrive about the button’s shape, size, and everything else.)</p>
<p>After this happens repeatedly, one thing becomes clear: <strong>when we extract commonalities from concrete requirements, even the resulting abstraction tends to reflect only the requirements that exist right now</strong>.</p>
<p>That is why I prefer the opposite direction. Instead of extracting abstractions from concrete things, I prefer to <strong>think of abstract parts first and assemble them into concrete things</strong>.</p>
<p>Imagine building a toast notification component. The initial requirement is simple: “When saving is complete, display a short message at the bottom.” An approach based on extracting commonalities would look like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 현재 요구사항의 공통 특성에서 출발한 설계</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ToastProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  hasAction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  actionLabel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onAction</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">hasAction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">actionLabel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">onAction</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ToastProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toast"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{message}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    {hasAction </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{onAction}>{actionLabel}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>The current requirement is handled perfectly. But a few days later, the product manager says, “Add an icon on the left depending on whether it is a success or a warning.” <code>hasIcon</code> and <code>iconName</code> are added. Soon another request arrives: “We also need a toast with an upload progress bar.” Another <code>progress</code> prop is added. After repeating this a few times, <code>Toast</code> ends up with more than ten props and hidden rules about <strong>which combinations are valid and which are not</strong> that developers must memorize. (And those rules are usually not documented even in comments.)</p>
<p>The design was vulnerable to change because it began with a <strong>concrete, present-day image of “what a toast should look like.”</strong></p>
<p>The story changes when we approach it as an assembly of parts.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 부품을 조립하는 설계</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"toast"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Icon</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "check"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "warn"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "info"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Message</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">span</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Action</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  children,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  onClick,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PropsWithChildren</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;{ </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{onClick}>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Toast.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Progress</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 필요한 부품만 골라 조립한다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>저장되었습니다&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Icon</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> name</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"check"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>업로드 완료&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Action</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{undo}>실행 취소&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Action</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Progress</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0.4</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>파일 전송 중...&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast.Message</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Toast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span></code></pre></figure>
<p>The stable essence—“a toast is a shallow container that holds something”—is now separated from the changeable concrete detail of “what it contains.” We can add as many new parts as we like, or arrange existing parts in new combinations, without touching <code>Toast</code> itself. The validity rules for prop combinations also disappear. We simply <strong>put in what we want to put in</strong>.</p>
<p>Of course, an experienced developer might say, “Couldn’t we just design it with IoC (Inversion of Control) from the beginning?” That is true. But such judgment is possible because countless past mistakes have cultivated an intuition for which parts are likely to change.</p>
<p>When that intuition is not yet well developed, beginning with the question, “What parts make up this feature, and how should those parts be assembled?” makes it much easier to create a design that is open to change.</p>
<p>At this point, a natural question arises: by what criteria should we divide the parts, and how should we present them externally?</p>
<h2 id="three-principles-for-good-abstraction"><a class="anchor" href="#three-principles-for-good-abstraction">Three Principles for Good Abstraction</a></h2>
<h3 id="think-carefully-about-expression"><a class="anchor" href="#think-carefully-about-expression">Think Carefully About Expression</a></h3>
<p>The most important virtue of an abstracted module is that its behavior should be inferable without opening its source code. Kent Beck called this the <strong>“Intention-Revealing Name”</strong> pattern and argued that if an abstraction cannot be given a concise name, the abstraction itself should be reconsidered.</p>
<p>Broadly speaking, we have two tools for achieving this: <strong>names</strong> and <strong>types</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 도대체 뭘 하는 건지 알 수 없는 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">price</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">rate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 이름과 타입만으로 동작을 유추할 수 있는 함수</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> calculateDiscountedPrice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">originalPrice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">discountRate</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>From the name alone, <code>calculateDiscountedPrice</code> tells us that it takes an original price and a discount rate and calculates the discounted price, while the type information supports this by showing that it accepts <code>number</code> values and returns a <code>number</code>. We do not need to know which calculation logic is applied internally.</p>
<p>By contrast, <code>calculate(price: number, rate: number): number</code> gives us no information about what is being calculated, so we cannot anticipate the result. We can only use it after opening the source code, which forfeits the benefit of abstraction.</p>
<p>It is worth noting that naming itself reflects the level of abstraction. Function names in programming generally take the form <strong>verb + noun</strong>, and the verb we choose reveals the level of abstraction at which the function operates.</p>
<blockquote>
<p>A verb alone does not determine the level of abstraction. The noun that accompanies it—the domain context—determines the final level.</p>
</blockquote>
<p>Certain verbs often appear at a <strong>low</strong> level of abstraction: <code>parse</code>, <code>encode</code>, <code>decode</code>, <code>serialize</code>, <code>read</code>, <code>write</code>, <code>push</code>, <code>pop</code>, <code>convert</code>, and <code>transform</code>. These words imply physical transformations of data or direct manipulation of data structures.</p>
<p>At the middle level, verbs such as <code>get</code>, <code>save</code>, <code>load</code>, and <code>validate</code> appear. They describe technical operations while revealing their intent to some degree.</p>
<p>At a <strong>high</strong> level of abstraction, verbs such as <code>register</code>, <code>refund</code>, <code>confirm</code>, <code>cancel</code>, and <code>submit</code> are used. These words belong to the language of the business domain. They reveal nothing about the technical procedures happening internally and express only <strong>a user action or business process</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 낮은 수준: 기술적 동작이 드러남</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> parseJSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">text</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> object</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> encodeBase64</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Uint8Array</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 중간 수준: 의도가 드러나되 기술적 맥락이 남아있음</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getUserById</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> validateEmail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">email</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 높은 수준: 비즈니스 의도만 드러남</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">form</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RegistrationForm</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> refundPayment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">orderId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> OrderId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">amount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Money</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Refund</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span></code></pre></figure>
<p>In <strong>Clean Code</strong>, Robert C. Martin wrote that <strong>“a long descriptive name is better than a short enigmatic name.”</strong> He also proposed the principle <strong>“pick one word per concept.”</strong> If <code>fetch</code>, <code>retrieve</code>, and <code>get</code> are mixed for operations in the same context, the reader is left wondering, “Do these three do different things?”</p>
<p>The same principle applies directly to naming React components and hooks.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />           </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SearchInput</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />      </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SubmitOrderButton</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>The specificity of a component’s name varies with its level of abstraction. Button is a general-purpose UI primitive at a lower level, whereas SubmitOrderButton clearly expresses business intent at a higher level.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> handleSubmit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FormData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> registerUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Form</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onSubmit</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleSubmit} />           </span></span></code></pre></figure>
<p><code>on*</code> is the name of the prop a component exposes externally. It lets the component’s consumer declare which event to respond to. <code>handle*</code> is the name of the implementation function actually passed to that prop.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();                  </span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">items</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setItems</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useCartItems</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isOpen</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">toggle</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useModal</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();   </span></span></code></pre></figure>
<p>Custom hooks use the <code>use</code> prefix to follow React’s rules and make the state or behavior they provide available to components.</p>
<blockquote>
<p>Jeff Atwood, who runs <a href="https://blog.codinghorror.com/" target="_blank" rel="noopener noreferrer">Coding Horror</a>, once pointed out the problem with the <code>Manager</code> suffix. A name like <code>UrlManager</code> tells us nothing about whether it pools, validates, or creates URLs. Names that reveal a specific role—such as <code>UrlBuilder</code>, <code>UrlValidator</code>, or <code>UrlPool</code>—are far better. An ambiguous name can be a sign that the module’s responsibility itself is ambiguous.</p>
</blockquote>
<p>Ultimately, a good name is one that <strong>immediately tells the reader the level of abstraction at which the code operates</strong>.</p>
<h3 id="deliberately-design-the-degree-of-input-freedom"><a class="anchor" href="#deliberately-design-the-degree-of-input-freedom">Deliberately Design the Degree of Input Freedom</a></h3>
<p>One question arises frequently when designing an abstracted module: “How much functionality should we expose?” This decision has a major impact on the experience of developers who use the module.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 기능이 닫힌 컴포넌트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Button</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>{children}&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 기능이 완전히 열린 컴포넌트</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Button</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ComponentProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"button"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">props} />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>The first button can accept only <code>children</code>. It cannot be given <code>onClick</code>, <code>type</code>, or <code>disabled</code>. In return, its users have nothing to deliberate over.</p>
<p>The second button can accept every attribute of the <code>button</code> element. It offers a high degree of freedom, but its users must decide which of dozens of props they should use. I describe this situation as <strong>“the component forcing developers to deliberate.”</strong></p>
<p>There is no single correct answer. The appropriate level depends on the module’s purpose and its users. For a design system’s base button, restricting Props may be better for maintaining consistency. For a general-purpose utility component, keeping the interface flexible may be preferable.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 지나치게 닫힌 인터페이스 — 다양한 상황에 대응 불가</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{handleSubmit}>제출&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// onClick 외의 이벤트, className, disabled 등을 전달할 방법이 없다</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 지나치게 열린 인터페이스 — 의도가 사라짐</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">anyProps} /></span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무엇을 전달해야 하는지 사용자가 직접 파악해야 한다</span></span></code></pre></figure>
<p>The breadth of an abstraction should be determined by who its users are. A low-level interface is appropriate for users who need to understand and finely control the internal implementation. On the other hand, giving an overly open interface to users who should not need to know the details only adds confusion. Conversely, restricting inputs too heavily for users who must handle varied situations can make the module unusable in the first place.</p>
<h3 id="keep-the-unit-of-abstraction-appropriate"><a class="anchor" href="#keep-the-unit-of-abstraction-appropriate">Keep the Unit of Abstraction Appropriate</a></h3>
<p>The unit of abstraction—how much to group into a single module—is another important consideration.</p>
<p>One common frontend antipattern is the over-extraction of Custom Hooks.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 이 훅은 단 하나의 컴포넌트에서만 사용된다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useUserProfileData</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">user</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">loading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setUser)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">finally</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, []);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { user, loading };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>If logic used by only one component is separated into a hook unnecessarily, the reader has to move back and forth between two files to understand the context. Instead of reducing context, the abstraction has increased it.</p>
<p>The reverse is also a problem: putting too much into a single hook.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 관련 없는 관심사가 하나의 훅에 뒤섞여 있다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useEverything</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> auth</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAuth</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> theme</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useTheme</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> analytics</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useAnalytics</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> toast</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useToast</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { auth, theme, analytics, toast };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>This kind of “God Hook” is difficult to test, and changing one thing can affect unrelated parts.</p>
<p>The criterion for choosing the right unit of abstraction is <strong>“Does this separation actually reduce the context required by the reader?”</strong> If separating the code instead scatters the context and makes it harder to understand, the time for that abstraction has not yet come.</p>
<h2 id="beware-of-premature-abstraction"><a class="anchor" href="#beware-of-premature-abstraction">Beware of Premature Abstraction</a></h2>
<p>After reading this far, one question may remain: “So when should we abstract?” My view is this: <strong>our default premise should be not to abstract prematurely.</strong></p>
<p>Unless there is a clear signal for abstraction, leaving code as it is gives us a better worst-case outcome than creating the wrong abstraction and later having to unravel it. The process by which a bad abstraction emerges tends to look like this.</p>
<ol>
<li>A similar pattern appears in code A and code B.</li>
<li>We think, “The DRY principle says I should extract this into a shared function!” and abstract it.</li>
<li>A similar pattern appears in code C, so we use the same function but add one parameter to accommodate slightly different behavior.</li>
<li>As code D and E begin using it as well, conditionals and parameters continue to accumulate.</li>
<li>The function is now used everywhere, yet everyone is afraid to touch it.</li>
</ol>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 처음에는 단순했던 함수가...</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatUserName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">firstName</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">} ${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">user</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">.</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">lastName</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 요구사항이 추가될 때마다 매개변수가 늘어나고...</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> formatUserName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  user</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> User</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  includeMiddleName</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  format</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "full"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "short"</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "initials"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  locale</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  honorific</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (format </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "initials"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (includeMiddleName </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> user.middleName) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (honorific </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> locale </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "ko"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    /* ... */</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...끝없는 분기</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>If we find ourselves in this situation, the solution is clear. Inline the abstracted code back into each call site, remove the unnecessary code from each one, and then, from that clean state, abstract again only if a genuine commonality becomes visible. <strong>“The fastest way forward is to go back.”</strong></p>
<p>Then when should we abstract? In my experience, the <strong>signals for abstraction</strong> look roughly like these.</p>
<ul>
<li><strong>Consistency is breaking down</strong>: The same logic is inline in one component and separated into its own function in another. The same calculation is scattered throughout the codebase.</li>
<li><strong>The internal structure is being exposed externally without need</strong>: The caller is forced to manage implementation details it has no reason to know.</li>
<li><strong>A module keeps exposing its own procedure</strong>: It fails to hide its internal steps, forcing consumers to follow those steps themselves.</li>
</ul>
<p>The problem is that while detecting these signals is generally straightforward, <strong>in practice, it is easy to ignore them and focus on satisfying more “important” requirements</strong>. Under deadline pressure or absorbed in implementing a feature, we tell ourselves, “It works for now; I’ll clean it up later,” and later rarely comes.</p>
<p>Another crucial point is to <strong>maintain consistent criteria for abstraction</strong>. If the same kind of logic is inline in one part of the codebase, extracted into a function in another, and separated into a custom hook somewhere else, a new reader will wonder, “Is there an intentional reason for these differences?” Whether a team chooses to abstract or not, its criteria must be consistent.</p>
<p>Joel Spolsky’s <strong>“Law of Leaky Abstractions,”</strong> proposed in 2002, is also worth remembering in this context. The law says that abstractions attempt to hide complex implementations, but the details of those implementations eventually leak out. In other words, even when an abstraction is designed so that its users should not need to know the internal implementation, situations arise in which they must understand it to use the abstraction correctly.</p>
<p>TCP abstracts an unreliable network so that it appears to be a reliable connection, but when a cable is disconnected, the abstraction breaks. React abstracts UI updates declaratively, but optimizing rerenders ultimately requires understanding its internal behavior. Because no abstraction is perfect, we must ask when creating one: <strong>“Can users respond when this abstraction breaks?”</strong></p>
<p>Ultimately, <strong>“Abstractions save us time working; they do not save us time learning.”</strong></p>
<h2 id="abstraction-is-something-we-internalize"><a class="anchor" href="#abstraction-is-something-we-internalize">Abstraction Is Something We Internalize</a></h2>
<p>I once discussed abstraction with a colleague, and something from that conversation stayed with me: detecting the signals for abstraction and separating code at the right time and at the right level are ultimately matters of <strong>intuition</strong>.</p>
<p>The principles discussed earlier—keeping levels of abstraction consistent, choosing good names, and designing the degree of input freedom—are certainly important. But trying to recall each principle and consciously weigh “Should I separate this or not?” at the very moment we write code can instead disrupt our flow. Just as consciously thinking about the angle of your elbow while throwing a jab during a match can make you miss the timing, abstraction while coding should emerge from natural intuition rather than conscious judgment.</p>
<p>There are moments while writing code when a feeling of resistance begins to surface: “This logic does not seem like it belongs here,” or “This component seems to know too much.” That feeling is the signal for abstraction, and internalization means being able to detect and respond to it naturally.</p>
<p>This intuition, however, is not built overnight. Only after studying countless patterns, reading a wide variety of code, and making plenty of mistakes yourself does the feeling that <strong>“this seems like something I should separate”</strong> begin to arise naturally. When a colleague later asks, “Why did you separate this?” and you can naturally answer, “I separated it because of X,” then the principle has become internalized.</p>
<p>I think the same is true in any field. Trying to become good by memorizing rules can make judgment harder. Ultimately, we need to hold on to the broad direction while letting the details fill themselves in naturally. And that naturalness comes from the variety of patterns and experiences we have accumulated over time.</p>
<h2 id="closing-thoughts"><a class="anchor" href="#closing-thoughts">Closing Thoughts</a></h2>
<p>Abstraction in programming is the act of hiding complexity so that it appears simple and allowing readers of the code to focus only on the context they need.</p>
<p>To revisit what is worth remembering in order to create good abstractions:</p>
<ul>
<li>As a default premise, do not abstract prematurely; separate code only when a clear signal appears.</li>
<li>Let each function speak at <strong>one level of abstraction</strong> only.</li>
<li><strong>Express</strong> behavior fully through names and types so that the code can be used without opening its source.</li>
<li><strong>Deliberately design</strong> the degree of input freedom to suit the module’s purpose and users.</li>
<li>Rather than piling more onto a bad abstraction, have the <strong>courage to unravel it and start again</strong>.</li>
<li>And <strong>internalize a variety of patterns</strong> until all of this comes naturally without conscious effort.</li>
</ul>
<p>Of course, what I have presented in this article is not the one correct answer. The appropriate level of abstraction inevitably varies with the business situation, the composition of the team, and the nature of the project. But if one thing remains constant, it is that the ultimate purpose of abstraction is <strong>to create code that people can understand easily</strong>.</p>
<p>I hope readers will ask this question of their own codebases: “Is this abstraction actually reducing context?” I believe that question alone can shift, at least slightly, the way we look at code.</p>
<h2 id="references"><a class="anchor" href="#references">References</a></h2>
<p>This article drew substantial inspiration from several official documents and earlier articles. Below are the sources of passages quoted directly, along with writings that helped shape the framework for my thinking.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://evan-moon.github.io/2023/01/15/what-is-abstract/" target="_blank" rel="noopener noreferrer">Evan Moon, Abstract and Abstraction</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/learn/reusing-logic-with-custom-hooks" target="_blank" rel="noopener noreferrer">React, Reusing Logic with Custom Hooks</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>design</category>
            <category>abstraction</category>
        </item>
        <item>
            <title><![CDATA[queryKey]]></title>
            <link>https://hooninedev.com/en/260104</link>
            <guid isPermaLink="false">https://hooninedev.com/en/260104</guid>
            <pubDate>Sun, 04 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to explore TanStack Query's queryKey. This is for frontend developers using TanStack Query who are deciding whether to keep queryKeys as inline arrays, gather them into a constant...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to explore <strong>TanStack Query's queryKey</strong>.</p>
<p>This is for frontend developers using TanStack Query who are deciding whether to keep queryKeys as inline arrays, gather them into a constant object or a query key factory, or move them to v5's <code>queryOptions</code>. By the end, you will know what problem each approach was introduced to solve, and you will have a basis for judging which stage fits your codebase now.</p>
<p>While using TanStack Query in production, I have <strong>completely overhauled how I manage queryKeys several times</strong>. At first, I simply wrote arrays like <code>['user', userId]</code> inline inside components. Then I started making typos because I had to repeat the same keys in multiple places whenever I invalidated queries, so I moved them into a constant object such as <code>QUERY_KEYS</code>. After reading TkDodo's article, I switched to the query key factory pattern. Much later, I adopted the <code>@lukemorales/query-key-factory</code> library. Then v5 arrived, and I overhauled everything once more using <code>queryOptions</code>.</p>
<h2 id="before-querykey"><a class="anchor" href="#before-querykey">Before queryKey</a></h2>
<p>Before we get into the main discussion, let's establish some context. Today, we take libraries like <code>TanStack Query</code> and <code>SWR</code> for granted, but how did we handle asynchronous data before they existed?</p>
<p>The most common approach was probably to combine <code>useState</code>, <code>useEffect</code>, <code>fetch</code>, and <code>axios</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserProfile</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">user</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">User</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">loading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">setError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> cancelled </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/users/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">userId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">res</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">())</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">then</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">err</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(err);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      })</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">finally</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">cancelled) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setLoading</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      cancelled </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, [userId]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The problem with this code is obvious. If just two components on the page display the same <code>userId</code>, <strong>the same request is sent twice.</strong> That happens because there is no cache. If the user navigates to another page and returns, the data is fetched from scratch yet again. There is no way to tell whether the data was fetched one second ago or one hour ago, so even approximating behavior such as "show cached data while refreshing in the background" is difficult. (You could build your own cache system to implement this, but I think it would be quite difficult to maintain.)</p>
<p>Redux combined with redux-thunk (or redux-saga) emerged as a way to solve this. By moving data-fetching logic into a thunk and storing the result in the store, other components could reuse the same data. But every request required defining action types, writing a reducer, and manually managing loading, success, and failure states. The amount of boilerplate needed to fetch a single piece of data was enormous. (I started working professionally during this era and found myself wondering, "Why do I need to create several files just to fetch one piece of data?")</p>
<p>The essence of this progression is ultimately this: <strong>"To avoid repeating a request, we need to be able to identify which request it is."</strong> The identifier for that "which request" is exactly what a queryKey is.</p>
<p>SWR and React Query (now TanStack Query) tackled this problem head-on. "An asynchronous request must have an identifier, and requests with the same identifier share a cache." That single, simple principle eliminated all the boilerplate above.</p>
<h2 id="the-essence-of-querykey"><a class="anchor" href="#the-essence-of-querykey">The essence of queryKey</a></h2>
<p>So what exactly is a queryKey? TanStack Query's official documentation defines it this way.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>At its core, TanStack Query manages query caching based on query keys. A query key must be an array at the top level... As long as the query key is serializable and <strong>unique to the query's data</strong>, you can use it.</p></div><div class="quote-original" lang="en"><p>At its core, TanStack Query manages query caching for you based on query keys. Query keys have to be an Array at the top level... As long as the query key is serializable, and <strong>unique to the query's data</strong>, you can use it.</p></div></blockquote>
<p>There are two key requirements. <strong>It must be serializable, and it must be unique to the data.</strong> The same key must represent the same data, and different data must have different keys. This simple rule determines how the entire cache system behaves.</p>
<p>There is one more important point: <strong>a queryKey also acts as a dependency array.</strong> Just as an effect runs again when its dependencies change in React's <code>useEffect</code>, TanStack Query automatically fetches new data when the queryKey changes.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>The queryKeys for a <code>userId</code> of <code>'A'</code> and one of <code>'B'</code> are different. A different key means a cache miss, and a cache miss triggers a fetch. It is automatic. Thanks to this simplicity, we do not need to write logic that says, "The userId changed, so fetch again."</p>
<p>So how does TanStack Query recognize an array that is created anew on every render as the same key? It does not compare array references; it uses the string produced by serializing the queryKey with <code>hashKey</code> (<code>queryHash</code>) as the cache key. This function sorts plain object keys while serializing with <code>JSON.stringify</code>, so key order inside an object does not affect the hash, while element order in an array does. A property whose value is <code>undefined</code> disappears during serialization, so <code>{ a: 1, b: undefined }</code> and <code>{ a: 1 }</code> become the same key. I have written up this behavior, following the implementation code, separately in <a href="/251230" target="_blank" rel="noopener noreferrer">How queryKey Comparison Works</a>.</p>
<h2 id="rules-for-writing-querykeys"><a class="anchor" href="#rules-for-writing-querykeys">Rules for writing queryKeys</a></h2>
<p>Once you understand that a queryKey is both the cache identifier and a dependency array, the rules for writing queryKeys follow naturally. The official recommendations can be summarized as follows.</p>
<h3 id="always-an-array"><a class="anchor" href="#always-an-array">Always an array</a></h3>
<p>Passing a string still works because it is converted to an array internally. For consistency, however, it is better to use an array from the start.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 비권장</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, queryFn });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 권장</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], queryFn });</span></span></code></pre></figure>
<h3 id="every-variable-the-queryfn-uses"><a class="anchor" href="#every-variable-the-queryfn-uses">Every variable the queryFn uses</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 잘못된 예: userId가 쿼리키에 없다</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 올바른 예</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>The mental model is exactly the same as the dependency array in <code>useEffect</code>. Every variable used inside the function must be part of the key (= dependency). Violating this rule creates bugs that are difficult to trace, such as continuing to display the previous user's data after switching to another user.</p>
<h3 id="from-generic-to-specific"><a class="anchor" href="#from-generic-to-specific">From generic to specific</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 좋다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { filter: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, todoId]</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 안 좋다 (순서가 뒤집혀 있음)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[{ filter: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span></code></pre></figure>
<p>This order matters because of <strong>invalidation</strong>. By default, TanStack Query's <code>invalidateQueries</code> uses <strong>prefix matching</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 todos 관련 쿼리 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → ['todos', 'list', ...], ['todos', 'detail', ...] 모두 매치된다</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// list 쿼리만 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// → ['todos', 'list', ...]만 매치된다</span></span></code></pre></figure>
<p>When keys are designed as a tree, a single line can express anything from "fetch all data in this domain again" to "fetch only this exact item again." (It may not seem like much at first, but after designing a key hierarchy poorly and seeing invalidation affect an unintended scope, its value becomes painfully clear.)</p>
<h2 id="the-evolution-of-querykey-management"><a class="anchor" href="#the-evolution-of-querykey-management">The evolution of queryKey management</a></h2>
<p>So far, we have covered how queryKey works and how to use it. Now we can move on to the central question: <strong>how has queryKey management changed over time?</strong></p>
<p>I will walk through the stages I have used in production, in chronological order.</p>
<h3 id="inline-arrays"><a class="anchor" href="#inline-arrays">Inline arrays</a></h3>
<p>This is the simplest form: combine fixed strings with prop values inside the component.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserProfile</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PostList</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filter</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> PostFilter</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'posts'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, filter],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchPosts</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filter),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>This is enough when you are just getting started.</p>
<p>The problems begin as the codebase grows. When a mutation that updates user information needs to invalidate a query, you have to search for the answer to "What was the query key for user data again?" every time. Some places end up using <code>['user', userId]</code>, while others use <code>['users', userId]</code> in the plural. These are entirely different cache slots, so invalidation affects only one of them.</p>
<h3 id="a-constant-object"><a class="anchor" href="#a-constant-object">A constant object</a></h3>
<p>To prevent typos, gather query keys into constants.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// queryKeys.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> QUERY_KEYS</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  USER: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  POSTS: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'posts'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  COMMENTS: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'comments'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용처</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QUERY_KEYS</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">USER</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>The typos disappear, but each call site is still responsible for assembling the key. One person may use <code>[QUERY_KEYS.USER, userId]</code>, another <code>[QUERY_KEYS.USER, userId, 'detail']</code>, and someone else <code>['user', 'detail', userId]</code>. Eventually, you need to memorize a separate convention just to know which form is correct.</p>
<h3 id="query-key-factory"><a class="anchor" href="#query-key-factory">Query Key Factory</a></h3>
<p>This pattern was formalized in TkDodo's <a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">Effective React Query Keys</a>. It defines an object that creates keys for each domain and expresses the hierarchy through functions.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// features/todos/queries.ts</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todoKeys</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  all: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.all, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), { filters }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.all, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), id] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), queryFn: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), queryFn: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.all });        </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 전체</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });    </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 리스트</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoKeys.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) });  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 항목</span></span></code></pre></figure>
<p>This pattern is powerful because <strong>the hierarchy is made explicit in the code</strong>. <code>todoKeys.all</code> refers to every query related to todos, <code>todoKeys.lists()</code> refers to every list query, and <code>todoKeys.detail(1)</code> refers to one specific item. The precise scope of invalidation can be expressed in a single line of code.</p>
<p>Another benefit is <strong>co-location</strong>. TkDodo does not recommend gathering keys into a global file. Instead, he recommends placing <code>queries.ts</code> inside the feature directory and keeping keys and hooks together there.</p>
<pre><code>src/
└── features/
    └── todos/
        ├── index.tsx
        └── queries.ts   # 키와 훅을 모두 여기에
</code></pre>
<p>This creates a simple mental model: "To change something about todos, I only need to look in the todos directory." It is a faithful application of the principle of keeping things that change together close together.</p>
<h3 id="lukemoralesquery-key-factory"><a class="anchor" href="#lukemoralesquery-key-factory">@lukemorales/query-key-factory</a></h3>
<p>Writing the third pattern by hand every time accumulates boilerplate. And when you want to combine keys from multiple domains, the lack of a standardized interface becomes apparent. <a href="https://github.com/lukemorales/query-key-factory" target="_blank" rel="noopener noreferrer">@lukemorales/query-key-factory</a> is the library form of this pattern.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createQueryKeys, mergeQueryKeys } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@lukemorales/query-key-factory'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> users</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> UserFilters</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [{ filters }],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filters),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todos</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [id],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getTodo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queries</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> mergeQueryKeys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(users, todos);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.todos.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 무효화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users._def);            </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 user 쿼리</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queries.users.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));   </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 항목</span></span></code></pre></figure>
<p><code>createQueryKeys</code> automatically adds the prefix, and <code>mergeQueryKeys</code> can combine domains. The conventional <code>_def</code> property provides access to the key for an entire domain. This eliminates the manual work of repeatedly adding <code>as const</code> to narrow types in a hand-written factory.</p>
<p>For a while, this library was effectively the standard. (I was a happy user for quite some time.) Then queryOptions changed the landscape.</p>
<h3 id="queryoptions-official-in-v5"><a class="anchor" href="#queryoptions-official-in-v5">queryOptions (official in v5)</a></h3>
<p>One of the most important changes in TanStack Query v5 was the introduction of the <code>queryOptions</code> API. During the transition from v4 to v5, every hook's arguments were unified into a single object. The real purpose of this change was to make it possible to extract that object into <strong>a reusable unit</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { queryOptions } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/react-query'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> userDetailOptions</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">userId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(userId),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 어디서나 사용 가능</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSuspenseQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">prefetchQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).queryKey, newUser);</span></span></code></pre></figure>
<p>At first glance, you might think, "What is different about this? It looks like an object wrapped in a function." TkDodo acknowledges this in <a href="https://tkdodo.eu/blog/the-query-options-api" target="_blank" rel="noopener noreferrer">The Query Options API</a>. At runtime, it truly does nothing more than return the object it receives.</p>
<p>The real work happens <strong>inside the type system</strong>. Let us continue.</p>
<h2 id="datatag-in-queryoptions"><a class="anchor" href="#datatag-in-queryoptions">DataTag in queryOptions</a></h2>
<p><code>queryOptions</code> is more than a simple helper because it <strong>embeds data type information in the returned queryKey</strong>. TanStack Query calls this mechanism <code>DataTag</code> internally.</p>
<p>A simplified implementation looks like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">declare</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dataTagSymbol</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unique</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> symbol</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">declare</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dataTagErrorSymbol</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unique</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> symbol</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> DataTag</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TError</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TType</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  [dataTagSymbol]</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  [dataTagErrorSymbol]</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>This is a <strong>branded type</strong> built with a <code>unique symbol</code>. It is merely a marker with no runtime effect, but to TypeScript it carries the information that "this array is not just an array; it is an array associated with data of type <code>TValue</code>."</p>
<p>There is a reason for using a <code>unique symbol</code> here. The Zenn article <a href="https://zenn.dev/tsuboi/articles/tanstack-query-options-unique-symbol?locale=en" target="_blank" rel="noopener noreferrer">Uncovering the unique symbol Behind DataTag</a> compares this mechanism to "a dedicated parking space for type information." An ordinary string key could collide with a key from another library or from user code, but <strong>each <code>unique symbol</code> declaration creates a type unique to itself</strong>, so it cannot have the same type as any other declaration. In effect, it becomes an identifier that can never collide.</p>
<p>The difference this one mechanism makes is significant.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// unknown</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">userDetailOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'abc'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).queryKey); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// User | undefined</span></span></code></pre></figure>
<p><code>getQueryData</code> and <code>setQueryData</code> receive only a queryKey. But because that queryKey already carries the data type, the return type is inferred automatically. There is no need to pass a generic explicitly, and if you try to provide <code>setQueryData</code> with a value of the wrong type, the compiler catches it immediately.</p>
<p>There are limitations, of course. Methods such as <code>getQueriesData</code>, which retrieve multiple queries at once, return a heterogeneous array of tuples, so this type inference does not apply. And because the implementation uses a <code>unique symbol</code>, generating <code>.d.ts</code> files in a monorepo can produce a TS4023 error; importing <code>dataTagSymbol</code> explicitly provides a workaround.</p>
<p>The mechanism so far makes one fact clear: <strong>the type inference provided by queryOptions depends entirely on declaring the queryKey and queryFn together in one place.</strong> To embed the return type of the queryFn in the queryKey, they must be declared together.</p>
<p>This has a major implication for the design direction of query key factories. Earlier patterns emphasized separating queryKey management into its own abstraction. The v5 recommendation takes the opposite direction: <strong>bring the queryKey and queryFn back together as a single unit</strong>. TkDodo goes so far as to say that separating the queryKey and queryFn was a mistake. After all, the key is a collection of the function's dependencies, and the two are inseparable.</p>
<h2 id="a-practical-queryoptions-composition-pattern"><a class="anchor" href="#a-practical-queryoptions-composition-pattern">A practical queryOptions composition pattern</a></h2>
<p>The real value of <code>queryOptions</code> emerges when it is combined with a domain-specific factory. The form recommended by the official v5 documentation looks like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { queryOptions } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/react-query'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> todoQueries</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'list'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  list</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">filters</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TodoFilters</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      queryKey: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), filters],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchTodos</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(filters),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">30</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'detail'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      queryKey: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">details</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(), id],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchTodo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      staleTime: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 60</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>Let us unpack the strengths of this pattern one by one.</p>
<h3 id="hierarchy-and-type-inference"><a class="anchor" href="#hierarchy-and-type-inference">Hierarchy and type inference</a></h3>
<p><code>todoQueries.all()</code> and <code>todoQueries.lists()</code> return plain arrays, while <code>todoQueries.detail(1)</code> returns an object created through <code>queryOptions</code> with a data tag attached. Use the arrays for invalidation and the options object for query calls.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">));                                </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 옵션 객체</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() }); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 배열</span></span></code></pre></figure>
<h3 id="per-component-option-overrides"><a class="anchor" href="#per-component-option-overrides">Per-component option overrides</a></h3>
<p>The result of <code>queryOptions</code> is ultimately an object, so individual options can be composed at the call site.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">title</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  select</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">todo</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> todo.title,  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 컴포넌트별로 다른 select 적용</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>What makes this especially powerful is that the return type of <code>select</code> is inferred automatically, narrowing the type of <code>data</code> to <code>string</code>. The component can select only the piece it needs while leaving the domain definition centralized.</p>
<h3 id="fewer-custom-hooks-around-usequery"><a class="anchor" href="#fewer-custom-hooks-around-usequery">Fewer custom hooks around <code>useQuery</code></a></h3>
<p>A common pattern in v4 was to create a custom hook for each domain.</p>
<p>The problem with that approach was that <strong>the moment prefetching became necessary, you had to write the same definition again</strong>. Because <code>useTodoDetail</code> is a hook, it cannot be called outside a component, so a router loader or event handler had to repeat <code>queryClient.prefetchQuery({ queryKey: [...], queryFn: ... })</code>.</p>
<p>With <code>queryOptions</code>, that duplication disappears.</p>
<p>The same definition works everywhere. That is why TkDodo recommends, "In v5, define queryOptions instead of creating hooks." Hooks become thin wrappers used only when needed, while the domain definition stands on its own without them.</p>
<h2 id="mutation-invalidation"><a class="anchor" href="#mutation-invalidation">Mutation Invalidation</a></h2>
<p>The queryKey hierarchy truly shines when invalidating queries after a mutation. According to TanStack Query's <a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-invalidation" target="_blank" rel="noopener noreferrer">Query Invalidation</a> documentation, <code>invalidateQueries</code> uses <strong>prefix matching</strong> by default.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 todos 관련 쿼리 (list, detail, lists 모두)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">all</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 모든 list만 (detail은 건드리지 않음)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">lists</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() });</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 정확히 이 키만 (자식 키 매치 안 함)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: todoQueries.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">detail</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).queryKey,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  exact: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 더 복잡한 조건은 predicate으로</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">invalidateQueries</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  predicate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    query.queryKey[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'todos'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    (query.queryKey[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)?.version </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 10</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>When keys are designed hierarchically, <strong>the scope of invalidation matches the meaning of the code.</strong> "Refresh all todos" is expressed with <code>all()</code>, "refresh only the lists" with <code>lists()</code>, and "refresh only this item" with <code>detail(id)</code>.</p>
<p>If the keys were scattered in a flat structure such as <code>['todoList']</code> and <code>['todoDetail', 1]</code>, invalidating the entire todos domain would require two separate calls or a separately managed prefix constant. (And if you forgot to update that constant whenever a new domain key was added, the new key would be omitted from invalidation, causing a bug.)</p>
<h2 id="reading-the-querykey-back-inside-queryfn"><a class="anchor" href="#reading-the-querykey-back-inside-queryfn">Reading the queryKey back inside queryFn</a></h2>
<p>There is one final pattern worth examining. A <code>queryFn</code> actually receives an object called <code>QueryFunctionContext</code>, which contains the queryKey used for that invocation.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, userId, { include: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'profile'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">options</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> queryKey;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUser</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id, options);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>Why is this pattern useful? According to TkDodo's <a href="https://tkdodo.eu/blog/leveraging-the-query-function-context" target="_blank" rel="noopener noreferrer">Leveraging the Query Function Context</a>, it can <strong>force the dependencies of queryKey and queryFn to stay synchronized</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> sortBy</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'name'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">],</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ sortBy }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>This code is risky because the queryFn depends on an external variable. The cache will not update when <code>sortBy</code> changes because the dependency was omitted from the key. As long as the <code>queryFn</code> pulls variables from an external closure, this mistake can happen at any time.</p>
<p>The solution is simple: make the <code>queryFn</code> independent of external variables. <strong>If every dependency is read from the queryKey</strong>, a variable omitted from the queryKey cannot be used inside the function in the first place.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'users'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { sortBy }] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: ({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: [, { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">sortBy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }] }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetchUsers</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ sortBy }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>With this structure, when a new dependency is introduced, there is no way to use it inside the function without adding it to the queryKey. The compiler catches the mistake by saying, in effect, "That key does not exist." Synchronization between the key and the function is delegated to <strong>the type system</strong> rather than left to convention.</p>
<h2 id="how-much-should-you-extract"><a class="anchor" href="#how-much-should-you-extract">How much should you extract?</a></h2>
<p>After reading this far, you may be wondering, "Should every query be extracted into <code>queryOptions</code>?"</p>
<p>As always, my answer is <strong>"it depends."</strong></p>
<p>The important thing to remember is that <strong>abstraction is not always beneficial</strong>. Extracting a query used only once into a domain factory merely forces readers to jump between two files. The evolution of queryKey management patterns does not mean "always use the most sophisticated tool." It means <strong>"you have the option to move up one rung of the ladder when the need arises."</strong></p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>In summary, a queryKey is <strong>the fundamental unit TanStack Query uses to identify and cache asynchronous data</strong>. That small array serves as a cache-slot identifier, a dependency array, and a definition of invalidation scope. In v5, it even carries data type information. Because so many responsibilities converge in this one place, how queryKeys are written and managed directly affects the cognitive load of the entire codebase.</p>
<p>Each stage was an answer to a real problem someone encountered at the time. So the right approach is not simply, "This is v5, so always use <code>queryOptions</code>." Instead, first ask: <strong>"What stage of this problem is my codebase currently facing?"</strong> Introducing a domain factory into a project where inline arrays are sufficient can itself be overengineering.</p>
<p>I hope this article encourages you to examine your own project: how queryKeys are distributed throughout the codebase, how invalidation is performed, and whether that structure fits the current size of your team and the complexity of your domain.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query, Query Keys</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-options" target="_blank" rel="noopener noreferrer">TanStack Query, Query Options</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/v5/docs/framework/react/typescript" target="_blank" rel="noopener noreferrer">TanStack Query, TypeScript</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tanstack.com/blog/announcing-tanstack-query-v5" target="_blank" rel="noopener noreferrer">TanStack, Announcing TanStack Query v5</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>queryKey</category>
        </item>
        <item>
            <title><![CDATA[How queryKey Comparison Works]]></title>
            <link>https://hooninedev.com/en/251230</link>
            <guid isPermaLink="false">https://hooninedev.com/en/251230</guid>
            <pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about how TanStack Query decides that two queryKeys are the same key. This is for TanStack Query users who have wondered why a queryKey, which is created as a new array on...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>how TanStack Query decides that two queryKeys are the same key</strong>.</p>
<p>This is for TanStack Query users who have wondered why a queryKey, which is created as a new array on every render, does not cause a cache miss every time, and whether object key order or <code>undefined</code> values affect the cache, and by the end you will know the rule that makes two keys equal and how that rule affects the cache. To give the conclusion first, TanStack Query uses the string produced by serializing the queryKey with <code>hashKey</code> as the cache key, and in that process object key order is ignored while array element order is preserved.</p>
<p>A queryKey is the array TanStack Query uses as the basis for managing the query cache. The same key means the same data, and when the key <code>['user', userId]</code> changes because <code>userId</code> changed, a cache miss occurs and the data is fetched again.</p>
<h2 id="inside-querycache"><a class="anchor" href="#inside-querycache">Inside QueryCache</a></h2>
<p>According to TkDodo's <a href="https://tkdodo.eu/blog/inside-react-query" target="_blank" rel="noopener noreferrer">Inside React Query</a>, <code>QueryCache</code> is ultimately just <strong>an in-memory data structure</strong>. In the v5 <a href="https://github.com/TanStack/query/blob/%40tanstack%2Fquery-core%405.104.1/packages/query-core/src/queryCache.ts" target="_blank" rel="noopener noreferrer">official implementation</a>, that data structure is not a plain object but a <code>Map&#x3C;string, Query></code>. The field is typed as <code>QueryStore</code>, and the constructor assigns it a <code>new Map&#x3C;string, Query>()</code>. Entries are stored and looked up with <code>queryHash</code> as the key. The key is the serialized form of the queryKey (<code>queryHash</code>), and the value is an instance of the <code>Query</code> class. The code and output in this post are based on <code>@tanstack/query-core</code> 5.104.1.</p>
<p>Unlike the plain object that older versions used, a <code>Map</code> never collides with keys inherited from a prototype.</p>
<p>What happens each time <code>useQuery</code> is called is straightforward. <strong>The queryKey is converted into a hash, and that hash is used to look it up in the Map.</strong> If an entry exists, TanStack Query retrieves the cached <code>Query</code> instance. Otherwise, it creates a new one and calls <code>set</code>.</p>
<p>This naturally leads to another question: <strong>why serialize the queryKey into a string at all?</strong> Why not use the array itself as the key, as in <code>Map&#x3C;QueryKey, Query></code>?</p>
<p>The answer lies in JavaScript's equality model. A native <code>Map</code> compares keys using <strong>reference equality</strong>. Even when their contents are identical, objects at different locations in memory are treated as different keys.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> m</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'alice'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">m.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// undefined. 새로 만든 배열은 다른 참조다</span></span></code></pre></figure>
<p>But in a React component, <code>useQuery({ queryKey: ['user', userId] })</code> <strong>creates a new array instance on every render.</strong> The queryKey arrays from the first and second renders are separate objects in memory even if their contents match. If the cache depended on reference equality, a component displaying the same data would miss the cache on every render.</p>
<p>The solution to the problem caused by reference equality is simple: <strong>convert reference equality into structural equality</strong>. Create a deterministic string using only the contents of the queryKey, then use that string as the Map key. This restores the semantics we want: "equal contents mean the same key." <code>JSON.stringify</code> is simply the most straightforward tool for that conversion.</p>
<h2 id="key-sorting-in-hashkey"><a class="anchor" href="#key-sorting-in-hashkey">Key Sorting in hashKey</a></h2>
<p>The function that produces the hash is <code>hashKey</code>. Its official implementation in <a href="https://github.com/TanStack/query/blob/%40tanstack%2Fquery-core%405.104.1/packages/query-core/src/utils.ts#L284-L295" target="_blank" rel="noopener noreferrer"><code>packages/query-core/src/utils.ts</code></a> looks like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MutationKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">val</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    isPlainObject</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(val)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(val)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">sort</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          .</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reduce</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">result</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">            result[key] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> val[key]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">            return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          }, {} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> val,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>It uses <code>JSON.stringify</code> with a <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#the_replacer_parameter" target="_blank" rel="noopener noreferrer">replacer callback</a> that <strong>sorts the keys of plain objects lexicographically</strong> before serialization. Strictly speaking, this is UTF-16 code unit order, the default comparison of <code>sort()</code>, so uppercase keys come before lowercase ones.</p>
<p>This sorting is fundamental because string serialization carries an additional, stronger requirement: <strong>semantically equivalent inputs must always produce the same string.</strong> Ordinary <code>JSON.stringify</code>, however, preserves key order. <code>{ a: 1, b: 2 }</code> and <code>{ b: 2, a: 1 }</code> are semantically equivalent objects, but they serialize into different strings and therefore occupy different cache slots. That would bring back duplicate requests for the same data.</p>
<p>The technique that consistently prevents this is a <strong>canonical form</strong>. It forces semantically equivalent inputs to map to exactly one representation. This is why the <code>hashKey</code> replacer sorts the keys of plain objects. By producing the same output regardless of input order, it makes semantically equivalent objects always become the same string. The reverse is not guaranteed, as we will see later.</p>
<p>The fact that arrays are not sorted is the other side of the same principle. An array is a data structure in which order itself carries meaning, so sorting it would destroy information. Object key order is incidental; array element order is intentional. <code>hashKey</code> treats the two accordingly. Maintainer TkDodo's advice in <a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">Effective React Query Keys</a> to structure a queryKey from the most generic to the most specific also follows from array order carrying meaning. The reason he gives is invalidation: keys that share the same leading part can all be invalidated at once with <code>['todos']</code>. That comparison is handled not by the hash but by prefix matching, which we will see later.</p>
<p>Key sorting applies only to <strong>plain objects</strong>. In the same file, <code>isPlainObject</code> checks whether <code>Object.prototype.toString</code> returns <code>[object Object]</code> and whether the prototype is <code>Object.prototype</code> (or <code>null</code>) to distinguish <strong>plain object literals</strong> from <strong>class instances</strong>. As a result, a literal such as <code>{ foo: 1 }</code> is sorted, while an instance created with <code>class User { ... }</code> passes through unsorted. If you put a class instance directly into a queryKey, its keys are not sorted, so it is serialized in the order its fields were assigned, and equal values can still produce different hashes.</p>
<p>Seen from the calling side, there are two results.</p>
<h3 id="object-key-order-does-not-matter"><a class="anchor" href="#object-key-order-does-not-matter">Object key order does not matter</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 쿼리는 같은 캐시 슬롯을 공유한다</span></span></code></pre></figure>
<p>Without key sorting, you would have to remember the key order every time you used an object literal.</p>
<h3 id="array-element-order-matters"><a class="anchor" href="#array-element-order-matters">Array element order matters</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status, page], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ queryKey: [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page, status], queryFn });</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 쿼리는 다른 캐시이다</span></span></code></pre></figure>
<h2 id="values-that-serialization-changes"><a class="anchor" href="#values-that-serialization-changes">Values That Serialization Changes</a></h2>
<p>It is also useful to know that <code>undefined</code> values disappear during serialization. <code>{ a: 1, b: undefined }</code> and <code>{ a: 1 }</code> produce the same hash. (I once made the mistake of thinking, "I explicitly included undefined, so this must be a different cache!")</p>
<p>Inside an array, it behaves differently. An <code>undefined</code> array element does not disappear; it becomes <code>null</code>. So <code>['user', undefined]</code> and <code>['user', null]</code> are the same key, and both differ from <code>['user']</code>. This is the case with <code>['user', userId]</code> when <code>userId</code> is still <code>undefined</code>. Even if you block the fetch with <code>enabled: false</code>, a <code>["user",null]</code> slot is still created in the cache. I confirmed this by creating the object that <code>useQuery</code> uses internally, a <code>QueryObserver</code>, with the same options.</p>
<p><code>undefined</code> is not the only such value. Because <code>hashKey</code> is built on top of <code>JSON.stringify</code>, most values that JSON cannot represent are turned into other values without any error. I ran the code below on 2026-10-08 with <code>@tanstack/query-core</code> 5.104.1 and Node v24.16.0, and the comments are the actual output.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { hashKey, QueryClient } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">undefined</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user",null]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user",null]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'user'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["user"]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'f'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">cb</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["f",{}]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'a'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["m",{}]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hashKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'d'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Date</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'2025-12-30T00:00:00Z'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ["d","2025-12-30T00:00:00.000Z"]</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryClient</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryClient</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'a'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'mapA'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'m'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'b'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]])])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// mapA</span></span></code></pre></figure>
<p>Running other values the same way gives the following summary.</p>
<table>
<thead>
<tr>
<th>Value in the key</th>
<th>Serialized result</th>
<th>Treated as the same key as</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>undefined</code>, <code>NaN</code>, <code>Infinity</code>, or a function as an array element</td>
<td><code>null</code></td>
<td>a key with <code>null</code> in that position</td>
</tr>
<tr>
<td><code>undefined</code> or a function as an object property</td>
<td>the property disappears</td>
<td>a key without that property</td>
</tr>
<tr>
<td><code>Map</code>, <code>Set</code></td>
<td><code>{}</code></td>
<td>every <code>Map</code>, <code>Set</code>, and empty object, regardless of contents</td>
</tr>
<tr>
<td><code>Date</code></td>
<td>an ISO string</td>
<td>the same ISO string</td>
</tr>
<tr>
<td><code>BigInt</code></td>
<td>throws <code>TypeError</code></td>
<td>none</td>
</tr>
<tr>
<td>circular reference</td>
<td>throws <code>RangeError</code> if the cycle runs only through plain objects, <code>TypeError</code> if it passes through an array or class instance</td>
<td>none</td>
</tr>
</tbody>
</table>
<p>The most dangerous ones are <code>Map</code> and <code>Set</code>. In the code above, data stored under <code>new Map([['a', 1]])</code> came back when looked up with <code>new Map([['b', 2]])</code>. Since there is no error, there is also no clue that the screen is rendering the wrong data.</p>
<p>Only two cases surface as errors: <code>BigInt</code> and circular references. For circular references, the error depends on what forms the cycle. A cycle that runs only through plain objects ends in <code>RangeError: Maximum call stack size exceeded</code>, while a cycle that passes through even one array or class instance, as in <code>arr.push(arr)</code>, ends in <code>TypeError: Converting circular structure to JSON</code>. Because the replacer returns a new object for every plain object, the cycle detection in <code>JSON.stringify</code> never sees the same object twice, whereas the replacer returns arrays and class instances as is, so the cycle is caught. <code>Date</code>, on the other hand, becomes an ISO string through <code>toJSON</code>, so for cache lookup the same instant produces the same key, which makes it relatively safe.</p>
<p>I once placed a <code>Date</code> directly in a key and spent a long time wondering, "Why is the cache refreshing even though it is the same instant?" A <code>Date</code> that points to the same instant becomes the same ISO string even if it is a different instance, so it produces the same hash. If a different key came out every time, the time itself was different, even if it looked like the same instant. Creating a <code>new Date()</code> during render puts a time that differs by milliseconds into every render, and each one becomes a new key.</p>
<p>So it is safest to put only strings, numbers, booleans, <code>null</code>, and arrays and plain objects made of them into a queryKey.</p>
<h2 id="querykeyhashfn"><a class="anchor" href="#querykeyhashfn">queryKeyHashFn</a></h2>
<p>There is an escape hatch from this constraint. Through the <code>queryKeyHashFn</code> option, TanStack Query lets you <strong>replace the hash function itself</strong>. Internally, <code>hashQueryKeyByOptions(queryKey, options)</code> branches: if <code>queryKeyHashFn</code> exists in the options, it calls that; otherwise, it calls the default <code>hashKey</code>.</p>
<p>Replacing it means substituting <code>hashKey</code> entirely. The key sorting described earlier goes away with it, so if you need sorting you have to implement it yourself. The case where this option is useful is a value that the default serialization throws on, such as <code>BigInt</code>. I ran the code below in the same environment.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { QueryClient } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bigintSafeHash</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">queryKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  JSON</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">stringify</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">v</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'bigint'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">toString</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> v))</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryClient</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryClient</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  defaultOptions: { queries: { queryKeyHashFn: bigintSafeHash } },</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">})</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">9007199254740993</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ok'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">9007199254740993</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">n</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ok</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'order'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'9007199254740993'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ok</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }], </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'A'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryClient.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getQueryData</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// undefined</span></span></code></pre></figure>
<p>The second output means that a <code>BigInt</code> and a string holding the same number become the same key. The last output is the result of losing key sorting: objects that differ only in key order are no longer treated as the same key.</p>
<p>Where you register it also changes the result. If you register it through the <code>QueryClient</code> <code>defaultOptions</code> or <code>setQueryDefaults</code>, as above, <code>setQueryData</code> and <code>getQueryData</code> use that function too. This is because both APIs merge the default options through <code>defaultQueryOptions</code> before hashing. If you put it only on a <code>useQuery</code> call, however, the imperative APIs use the default <code>hashKey</code>, and the same key splits into two slots in the cache. In the v3.2.0 beta period, even the global default was not applied to <code>setQueryData</code>, and the reporter of <a href="https://github.com/TanStack/query/issues/1343" target="_blank" rel="noopener noreferrer">Issue #1343</a> confirmed it was fixed in v3.2.0-beta.30.</p>
<p>In production, it is therefore much safer to avoid the escape hatch and <strong>convert values into a serializable form when constructing the queryKey</strong>. Writing your own hash function means you have to take care of both key sorting and where it is registered.</p>
<h2 id="how-filters-compare-keys"><a class="anchor" href="#how-filters-compare-keys">How Filters Compare Keys</a></h2>
<p>There is one more way two keys are judged equal. The "same key" discussed so far means the hash strings are equal, and hashes are used only for cache lookup and <code>exact: true</code> filters. Filters such as <code>invalidateQueries</code> and <code>findAll</code> decide by default with <code>partialMatchKey</code>, which recursively compares the structure of the original queryKey rather than the hash string. Arrays are matched from the front, and objects are checked only for the keys written in the filter. I ran the code below in the same environment as well.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { partialMatchKey } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> '@tanstack/query-core'</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> queryKey</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, page: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }]</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// true</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// true</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [{ status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'done'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// false</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">partialMatchKey</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(queryKey, [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todos'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, { status: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'todo'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }])) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// false</span></span></code></pre></figure>
<p>Since this matching does not go through the hash, replacing <code>queryKeyHashFn</code> does not change it. In a <code>QueryClient</code> registered with an unsorted hash function like the earlier example, an object that differs only in key order cannot be found with <code>exact: true</code>, but prefix matching still finds it.</p>
<p>So keys that are equal by hash are not guaranteed to be equal in a filter. A query created with <code>['user', undefined]</code> is not caught by prefix matching with a <code>['user', null]</code> filter, and a key containing <code>NaN</code> does not even match itself. Conversely, if you put a <code>Date</code> or a <code>Map</code> into a filter, it has no enumerable properties to compare, so it matches any <code>Date</code> or object in the same position.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>In short, TanStack Query does not compare queryKey array references. Instead, <code>hashKey</code> sorts plain object keys while serializing with <code>JSON.stringify</code>, and the resulting string (<code>queryHash</code>) becomes the key of a <code>Map</code>. As a result, object key order does not affect the cache, array element order does, and a property whose value is <code>undefined</code> is, for hashing, the same as an absent one. Most values that JSON cannot represent turn into other values without any error, so different keys silently become the same key. You can swap the hash function with <code>queryKeyHashFn</code>, but that also throws away key sorting, so it is safer to convert values into serializable ones when building the key. Filters such as invalidation, by default, skip the hash and match the structure of the queryKey from the front, so it is better not to expect keys that are equal by hash to be equal in a filter. In the end, cache lookups compare a key's content through <code>hashKey</code> and filters compare its leading part, so if you put only simple values whose meaning survives serialization into a queryKey, both comparisons behave as you expect.</p>
<p>How this rule carries over to writing and managing queryKeys, that is, the path from inline arrays through query key factories to <code>queryOptions</code>, is covered in <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a>.</p>
<p>Next time you put an object or a <code>Map</code> into a queryKey, I hope you will stop for a moment and think about what string it will be serialized into.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query, Query Keys</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/classes/QueryClient" target="_blank" rel="noopener noreferrer">TanStack Query, QueryClient</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>queryKey</category>
        </item>
        <item>
            <title><![CDATA[Placing ErrorBoundary]]></title>
            <link>https://hooninedev.com/en/251203</link>
            <guid isPermaLink="false">https://hooninedev.com/en/251203</guid>
            <pubDate>Wed, 03 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about how many ErrorBoundary components to have and where to put them. This is for frontend developers building screens with React Router and TanStack Query who keep weigh...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>how many <code>ErrorBoundary</code> components to have and where to put them</strong>. This is for frontend developers building screens with React Router and TanStack Query who keep weighing whether a failure should be received by the route's <code>ErrorBoundary</code>, by <code>react-error-boundary</code>, or by <code>useQuery</code>'s <code>isError</code>. By the end, you will have a criterion for deciding where <code>ErrorBoundary</code> goes, and a way to match the fallback and the retry condition to each place.</p>
<p>To give the conclusion first, the number of <code>ErrorBoundary</code> components is set not by taste but by two things: <strong>what throws</strong>, and <strong>what has to remain on screen when it dies</strong>. This post starts with the latter and covers the former where it is needed.</p>
<h2 id="the-blast-radius-you-can-afford"><a class="anchor" href="#the-blast-radius-you-can-afford">The blast radius you can afford</a></h2>
<p>When people ask where to put an <code>ErrorBoundary</code>, they usually think about which component to wrap. That question has no answer. There are always several components you could wrap, and the code runs no matter which one you pick.</p>
<p>Ask it differently. <strong>If this dies, what has to remain on screen?</strong></p>
<p>The answer differs from place to place. If the data that forms the backbone of a screen is missing, that screen does not hold together. An action button sitting alone, with no name and no status beside it, means nothing. Conversely, if one peripheral list fails and you blank out the whole screen, the user loses everything else they could have seen perfectly well. Actions that are hard to undo are different again. The user has to learn about the failure right where they clicked.</p>
<p><strong>What an <code>ErrorBoundary</code> does is not catch a failure. It decides the area the fallback gets drawn over.</strong> The fallback is what you draw instead of the original screen when something fails. Everything you wrapped disappears. So the position of an <code>ErrorBoundary</code> is decided not by what you want to catch, but by <strong>the blast radius you can afford</strong>.</p>
<p>That radius splits into four levels.</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>What it receives</th>
<th>How you undo it</th>
</tr>
</thead>
<tbody>
<tr>
<td>Route</td>
<td>what the loader threw, whatever nothing below caught</td>
<td><code>revalidate()</code></td>
</tr>
<tr>
<td>Screen</td>
<td>what was thrown during render</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>Region</td>
<td>what was thrown inside it</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>Inside the component</td>
<td>a failed <code>useQuery</code>, a failed mutation</td>
<td><code>refetch()</code>, a toast</td>
</tr>
</tbody>
</table>
<p><picture><source type="image/webp" srcset="/content/251203/1.720w.webp 720w, /content/251203/1.1440w.webp 1440w, /content/251203/1.1800w.webp 1800w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251203/1.png" alt="Nested rectangles: a route ErrorBoundary contains a screen ErrorBoundary, which in turn contains a region ErrorBoundary and an inside-the-component box side by side. Arrows enter from the left: what the loader threw goes to the route ErrorBoundary, what was thrown during render goes to the screen ErrorBoundary, that region&#x27;s failure goes to the region ErrorBoundary, and what never throws goes to the inside-the-component box. An arrow pointing upward out of the inside-the-component box is crossed out in red and labeled as not travelling upward" style="max-width:min(720px, 100%);height:auto;" width="1800" height="1000" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>There are four names but only two kinds. <strong>Only the route <code>ErrorBoundary</code> belongs to the router; the other two are <code>ErrorBoundary</code> you place in the tree.</strong> The screen <code>ErrorBoundary</code> and the region <code>ErrorBoundary</code> are the same component, differing only in where they are attached. The bottom row, inside the component, is not an <code>ErrorBoundary</code> at all but a branch the component draws itself.</p>
<p>The sections below look at why you cannot delete any row of this table.</p>
<h2 id="three-layers-you-cannot-delete"><a class="anchor" href="#three-layers-you-cannot-delete">Three layers you cannot delete</a></h2>
<p>Once you install <code>react-error-boundary</code>, the router's side starts to look deletable. The names match, so the jobs look like they match too. But there are three reasons you cannot delete any one of them. The three counted here are route, screen, and region from the table above. Inside the component is excluded because it is not an <code>ErrorBoundary</code>.</p>
<h3 id="the-loader-lives-outside-errorboundary"><a class="anchor" href="#the-loader-lives-outside-errorboundary">The loader lives outside ErrorBoundary</a></h3>
<p>An <code>ErrorBoundary</code> is a class built from <code>getDerivedStateFromError</code> and <code>componentDidCatch</code>, so only <strong>what React caught inside the tree</strong> reaches it. A loader is a function that runs outside the tree, before rendering even starts. What it throws never passes through React, so no amount of wrapping makes it visible. How I confirmed the destinations by throwing the same error from several places is written up in <a href="/251117" target="_blank" rel="noopener noreferrer">Error propagation</a>.</p>
<p>That means if even one route uses a loader, <strong>you cannot delete the route <code>ErrorBoundary</code>.</strong> The moment you do, that failure has nowhere to go.</p>
<h3 id="the-cache-revalidate-cannot-clear"><a class="anchor" href="#the-cache-revalidate-cannot-clear">The cache revalidate cannot clear</a></h3>
<p>The opposite direction is blocked too. The recovery mechanism of the route <code>ErrorBoundary</code> is <code>revalidate()</code>, and that only re-runs the loader. It does not touch the query cache.</p>
<p>Consider a route with no loader. If a <code>useSuspenseQuery</code> inside the screen fails, the route <code>ErrorBoundary</code> does end up receiving it. The router wraps the route tree in a class of its own called <code>RenderErrorBoundary</code>, and that class also has <code>getDerivedStateFromError</code>. But pressing retry in that <code>ErrorBoundary</code> gets you nowhere: <strong>there is no loader to re-run, and the error stuck in the cache is still there.</strong> It received the failure but has no way to undo it.</p>
<p><strong>Receiving and undoing are different jobs.</strong> If you do not look at both when placing an <code>ErrorBoundary</code>, you end up with a fallback that catches things nobody can clear.</p>
<h3 id="a-lower-errorboundary-narrows-the-radius"><a class="anchor" href="#a-lower-errorboundary-narrows-the-radius">A lower ErrorBoundary narrows the radius</a></h3>
<p>The third one is not about being blocked. It is about losing too much.</p>
<p>When something fails, the router picks one <code>ErrorBoundary</code>. The way it picks is in the source.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> findNearestBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">matches</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">routeId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">slice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">findIndex</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">matches];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reverse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.hasErrorBoundary </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>It walks the matched routes from the back, picks the first route that has an <code>ErrorBoundary</code>, and falls back to the very first one if there is none. If a child in a nested route has no <code>ErrorBoundary</code>, <strong>the parent</strong> takes that job, and if the parent has none either, the root takes it.</p>
<p>When the root takes it, the whole screen disappears. Only one inner region failed, yet the header and the navigation go with it. Attach an <code>ErrorBoundary</code> to the child route and the fallback is drawn only in the <code>&#x3C;Outlet /></code> slot.</p>
<p><strong>So adding one more <code>ErrorBoundary</code> below is not duplication.</strong> It does not catch the same failure twice; it <strong>changes how much gets erased</strong>. A layout that looks like three overlapping boundaries is really three different things being received at three different radii.</p>
<h2 id="between-a-region-errorboundary-and-inside-the-component"><a class="anchor" href="#between-a-region-errorboundary-and-inside-the-component">Between a region ErrorBoundary and inside the component</a></h2>
<p>Once the layers are split four ways, one last fork remains. For a region the screen holds together without, you choose whether to <strong>lift it into an <code>ErrorBoundary</code> or handle it in place</strong>.</p>
<p>Either way you lose only that region and keep the rest. What differs is not how much you lose but <strong>what you draw there instead</strong>.</p>
<p>Lifting it into an <code>ErrorBoundary</code> shrinks the code. Call <code>useSuspenseQuery</code> inside and that component has neither <code>isPending</code> nor <code>isError</code>. The outer <code>Suspense</code> takes the waiting, the outer <code>ErrorBoundary</code> takes the failure. The component only draws the case where data exists.</p>
<p>The <code>QueryAsyncBoundary</code> below is a component that bundles that <code>Suspense</code> and <code>ErrorBoundary</code> together. Inside <code>QueryErrorResetBoundary</code> it places an <code>ErrorBoundary</code>, wires its <code>onReset</code> to <code>reset</code>, and wraps the inside in <code>Suspense</code>. Putting <code>Query</code> in the name is deliberate. Call it <code>AsyncBoundary</code> and it reads as usable for any async work, which it is not, because <code>QueryErrorResetBoundary</code> sits inside it. For the same reason I gave <code>pendingFallback</code> no default value. With a default, a single line at the call site does not tell you what gets laid down.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">section</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>댓글&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryAsyncBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> pendingFallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>불러오는 중&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CommentList</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{postId} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryAsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">section</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>In exchange, that whole region turns into the fallback. For a spot like a list, where nothing is worth keeping if the whole thing goes, that costs nothing.</p>
<p>Handling it in place is the opposite. You call <code>useQuery</code> and draw the four states (pending, failed, empty, present) yourself. The branches multiply, but in return you can put <strong>wording and actions tailored to that spot</strong>. If you want a small retry button on just the row that failed, this is the way. Lift it into an <code>ErrorBoundary</code> and the shape of that row gets decided by the fallback component, which is usually the one shared with the screen <code>ErrorBoundary</code> and is far too heavy for a single failed row.</p>
<p>The criterion comes out like this. <strong>If the region can vanish whole, <code>ErrorBoundary</code> is better; if you need wording or actions tailored to that spot, <code>useQuery</code> is better.</strong> An <code>ErrorBoundary</code> takes the branches out of your component, but it takes the freedom to decide the screen along with them.</p>
<p>If you decide not to lift it into an <code>ErrorBoundary</code>, <strong>you have to handle all four states.</strong></p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">data</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isPending</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">refetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">isRefetching</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">commentsOptions</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(postId))</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isPending) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>불러오는 중&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (isError) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> role</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"alert"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>댓글을 불러오지 못했어요&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onClick</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> refetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">disabled</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{isRefetching}>재시도&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">button</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (data.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>아직 댓글이 없어요&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>Leave out <code>isError</code> and the failure quietly flows into the empty state. The <code>data</code> of a failed query is <code>undefined</code>, and a check like <code>data == null || data.length === 0</code> does not distinguish that <code>undefined</code> from an empty array. The screen says there are no comments yet while the server is handing out a 500 at that very moment.</p>
<p>Filter <code>isError</code> out first and <code>data</code> narrows to an array below it, and the <code>== null</code> check disappears.</p>
<h2 id="a-different-fallback-per-layer"><a class="anchor" href="#a-different-fallback-per-layer">A different fallback per layer</a></h2>
<p>Now that the layers are placed, decide what each one draws on screen. Here the route <code>ErrorBoundary</code> and the <code>ErrorBoundary</code> you place in the tree diverge again, and <strong>the criterion for choosing is not taste but how that layer is handed the error</strong>.</p>
<h3 id="the-route-errorboundary-reads-it-itself"><a class="anchor" href="#the-route-errorboundary-reads-it-itself">The route ErrorBoundary reads it itself</a></h3>
<p>A route <code>ErrorBoundary</code> does not need the error passed down to its fallback. The component reads it directly with <code>useRouteError()</code> and fetches its recovery mechanism on its own.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> RootErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRouteError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">revalidate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useRevalidator</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{revalidate} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">retrying</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'loading'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>So all a route needs is the component plugged in. A child route's <code>ErrorBoundary</code> has the same shape and uses the same hooks. What separates the two is not the code but <strong>the route it is attached to</strong>. Which route it sits on is exactly the radius the fallback is drawn over.</p>
<h3 id="the-errorboundary-that-hands-it-down"><a class="anchor" href="#the-errorboundary-that-hands-it-down">The ErrorBoundary that hands it down</a></h3>
<p>In <code>react-error-boundary</code>, the <code>ErrorBoundary</code> is the opposite. It holds both the error and the reset function itself, so it has to hand them down to the fallback. That is why there are three props, and the type definition binds the three as mutually exclusive.</p>
<p><strong><code>fallback</code></strong> takes a finished element as is. You write it like <code>fallback={&#x3C;p role="alert">댓글을 불러오지 못했어요&#x3C;/p>}</code>. It gives you neither the error nor the reset function. It is only usable when the message is fixed and there is no way to retry anyway.</p>
<p><strong><code>FallbackComponent</code></strong> takes a component and passes <code>error</code> and <code>resetErrorBoundary</code> to it as props. It is tidy when several <code>ErrorBoundary</code> share the same fallback, but the prop name is fixed as <code>resetErrorBoundary</code>, so the receiving side has to know that name.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CommentsFallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FallbackProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">FallbackComponent</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{CommentsFallback}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">CommentList</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{postId} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>The <code>reset</code> in this code is the function TanStack Query's <code>QueryErrorResetBoundary</code> passes down. It clears queries left in an error state so that a retry actually refetches; why that is needed is traced through the source in <a href="/251128" target="_blank" rel="noopener noreferrer">Why the Retry Button Does Nothing</a>.</p>
<p><code>ErrorFallback</code> takes <code>onRetry</code>, so the names do not line up. That costs one more component whose only job is to move the name across.</p>
<p><strong><code>fallbackRender</code></strong> takes a function and renders it right there.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  fallbackRender</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>I picked this one. The reason is <strong>that you can swap the name right there</strong>. Above, <code>resetErrorBoundary</code> became <code>onRetry</code>. Thanks to that, <code>ErrorFallback</code> becomes a component that knows only <code>error</code> and <code>onRetry</code>, and the <code>revalidate</code>-backed route <code>ErrorBoundary</code> and the <code>resetErrorBoundary</code>-backed tree <code>ErrorBoundary</code> <strong>use the same fallback.</strong></p>
<p><strong>Two layers whose screens do not drift apart is what this choice buys.</strong> Split the layers four ways and the failure screens the user sees risk becoming four as well, and swapping one name makes them one.</p>
<h2 id="failures-you-do-not-attach-retry-to"><a class="anchor" href="#failures-you-do-not-attach-retry-to">Failures you do not attach retry to</a></h2>
<p>Putting a retry button in the fallback does not clear every failure, because an <code>ErrorBoundary</code> only resets its own state. A query's error is refetched only once <code>QueryErrorResetBoundary</code>'s <code>reset</code> is connected to <code>onReset</code>; a render error from reading an unexpected value the server sent with a 200 clears only once <code>queryFn</code> checks it first and turns it into the query's error; and a chunk load failure of <code>React.lazy</code> clears only with a reload. How I checked the three cases in the source is written up separately in "Why the Retry Button Does Nothing," linked above. Since failures split into recoverable and unrecoverable this way, the button has to be split too.</p>
<p>Showing the same button on every failure means <strong>guiding the user toward an action they cannot take</strong>. Press retry on a 404 and you get the same 404. A 403 from missing permissions is the same. A chunk load failure does nothing at all, as shown above.</p>
<p>Splitting it once inside the shared fallback is enough.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> describe</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isChunkLoadError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'새 버전이 배포됐어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'새로 고침하면 이어서 볼 수 있어요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'reload'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> status</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isHttpError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> isRouteErrorResponse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 404</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'찾을 수 없어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'주소가 바뀌었거나 삭제된 항목이에요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> undefined</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 400</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 500</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'요청을 처리할 수 없어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'입력한 내용을 다시 확인해 주세요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { title: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'불러오지 못했어요'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, description: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'잠시 후 다시 시도해 주세요.'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, action: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'retry'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>There is a reason <code>isRouteErrorResponse</code> is in here too. The route <code>ErrorBoundary</code> and the <code>ErrorBoundary</code> in the tree share a fallback, so this function <strong>receives both kinds of error.</strong> Reading the status code off a <code>Response</code> the router threw and reading it off an HTTP error you built yourself work differently, so it checks both.</p>
<p>You do not need a huge branch from the start. Separating <strong>failures that will come out differently when pressed again from those that will not</strong> is enough.</p>
<h2 id="when-the-errorboundary-sees-the-failure"><a class="anchor" href="#when-the-errorboundary-sees-the-failure">When the ErrorBoundary sees the failure</a></h2>
<p>The <code>ErrorBoundary</code> are all placed, and one thing is still missing. <strong>When does an <code>ErrorBoundary</code> see anything?</strong></p>
<p>The layers were decided top down, but the execution order is the reverse. The <code>ErrorBoundary</code> sees something only after every retry has been exhausted. So the retry condition is <strong>part of designing the <code>ErrorBoundary</code></strong>.</p>
<p>The default differs per place. Both use the same <code>createRetryer</code>, but they pass different values into it. A query passes nothing and takes the default from <code>retryer.js</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> retry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> config.retry </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isServer</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> :</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 3</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>A mutation puts 0 in directly, in <code>mutation.js</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">retry</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.options.retry </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span></code></pre></figure>
<p><strong>A mutation at 0 means that once a request ends in an error, the action fails right away.</strong> No retries for actions that are hard to undo is a safe default, but if the action is idempotent, sending it once more is better for the user.</p>
<p>Stating the same condition on both sides is better.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> MAX_RETRY</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> retryOnServerError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">failureCount</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> number</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> unknown</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> boolean</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (failureCount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> MAX_RETRY</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isHttpError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(error) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error.status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">>=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 500</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>failureCount</code> starts at 0 and <strong>asks with 0 on the first failure.</strong> So with <code>MAX_RETRY</code> at 2 it sends three requests in total and stops. It is a retry count, not an attempt count.</p>
<p><strong>Excluding 4xx is the key part.</strong> The request itself is wrong, so the same answer comes back however many times you send it. But waste is not the only reason to exclude them. The default delay grows exponentially.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultRetryDelay</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">failureCount</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">min</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1e3</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> *</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> **</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> failureCount, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">3e4</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The first retry is 1 second and the next is 2, so two retries alone burn 3 seconds. Fail to filter out 4xx and you are <strong>hiding an unfixable failure for 3 seconds</strong>.</p>
<p>Render errors and chunk failures have no such delay. No request goes out, so there is no retry at all, and the <code>ErrorBoundary</code> sees it the moment it is thrown. <strong>This is why the same fallback appears 3 seconds later for some failures and immediately for others.</strong></p>
<p>There is one premise to confirm before turning retries on. Is that request <strong>idempotent?</strong> Sending the same request twice has to produce the same result. If the server does not guarantee that, retrying is a feature that manufactures bugs.</p>
<h2 id="wrapping-up"><a class="anchor" href="#wrapping-up">Wrapping up</a></h2>
<p>In this post I decided what to put in each place that receives a failure. To summarize:</p>
<ul>
<li>The position of an <code>ErrorBoundary</code> is decided by the blast radius you can afford. You ask not what to wrap, but what has to remain when this dies.</li>
<li>You cannot delete any of the three layers of <code>ErrorBoundary</code>. What the loader threw cannot reach the <code>ErrorBoundary</code> in the tree, <code>revalidate</code> cannot clear the query cache, and adding one more below narrows the radius.</li>
<li>If the region can be thrown away whole, <code>ErrorBoundary</code>; if you need wording specific to that spot, <code>useQuery</code>. Choose the latter and you have to handle all four states.</li>
<li>Swap the name with <code>fallbackRender</code> and the fallback becomes one even across different layers.</li>
<li>Retry works only once you clear the state held by whatever threw. A query is <code>reset</code>, a render error is lifted into <code>queryFn</code>, a chunk is a reload.</li>
<li>When an <code>ErrorBoundary</code> sees the failure is decided by the retry condition. Fail to filter out 4xx and you hide an unfixable failure for seconds.</li>
</ul>
<p>You could ask whether one <code>ErrorBoundary</code> and a toast are not enough. For a product with two or three screens that is a fair point, and in fact <strong>the number of layers is decided by the product.</strong> If there is only one screen, there is only one screen to give up, so differences in radius are invisible. The more independent regions there are inside a screen, the larger that difference gets.</p>
<p>Something survives regardless of the number, though. However many <code>ErrorBoundary</code> you put in the tree, what <code>loader</code> threw does not go there, and retry does nothing unless you clear the state held by whatever threw. <strong>What you can reduce is the layers, not these facts.</strong></p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary" target="_blank" rel="noopener noreferrer">TanStack Query, QueryErrorResetBoundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/suspense" target="_blank" rel="noopener noreferrer">TanStack Query, Suspense</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router, Error Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tkdodo.eu/blog/react-query-error-handling" target="_blank" rel="noopener noreferrer">TkDodo, React Query Error Handling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://tkdodo.eu/blog/mastering-mutations-in-react-query" target="_blank" rel="noopener noreferrer">TkDodo, Mastering Mutations in React Query</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>error-handling</category>
        </item>
        <item>
            <title><![CDATA[Why the Retry Button Does Nothing]]></title>
            <link>https://hooninedev.com/en/251128</link>
            <guid isPermaLink="false">https://hooninedev.com/en/251128</guid>
            <pubDate>Fri, 28 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about why the retry button on an ErrorBoundary does nothing. This is for frontend developers who put a retry button in a react-error-boundary fallback, only to see the sam...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>why the retry button on an <code>ErrorBoundary</code> does nothing</strong>.</p>
<p>This is for frontend developers who put a retry button in a <code>react-error-boundary</code> fallback, only to see the same screen come back when they press it, and it lays out the three cases where the failed state remains and how to clear each one. The short answer: an <code>ErrorBoundary</code> only resets its own state, and the state that caused the failure stays with whatever threw.</p>
<p>The examples use TanStack Query together with <code>react-error-boundary</code>. I checked the libraries' behavior by opening their installed source, and the quoted code has the same structure as the build files of <code>@tanstack/react-query</code> 5.104.1, <code>react-error-boundary</code> 6.1.6, and React 19.2.3 (compared on 2026-10-08). Indentation and line breaks are adjusted in places for readability.</p>
<h2 id="three-cases-where-retry-does-nothing"><a class="anchor" href="#three-cases-where-retry-does-nothing">Three cases where retry does nothing</a></h2>
<p>There are three causes, and each one is undone in a different place. They have one thing in common. <strong>An <code>ErrorBoundary</code> only undoes its own state.</strong> State held by whatever threw has to be cleared by whatever threw.</p>
<h3 id="the-query-error-reset-clears"><a class="anchor" href="#the-query-error-reset-clears">The query error reset clears</a></h3>
<p>All <code>resetErrorBoundary()</code> does is put the <code>ErrorBoundary</code>'s internal state (<code>didCatch</code>) back. When the children remount, the query hook reads the cache again. But that query is <strong>stuck in the cache in an error state.</strong> So the hook throws the same error right away during render and the <code>ErrorBoundary</code> draws the fallback again.</p>
<p>Why it uses the old error instead of refetching is in the source too. <code>errorBoundaryUtils.js</code> turns off the refetch on remount like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (options.suspense </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> throwOnError) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">errorResetBoundary.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) options.retryOnMount </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> false</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Read the outer guard first. <strong>This condition only applies to queries that throw.</strong> When a query with <code>suspense</code> on, or with <code>throwOnError</code> on, mounts while <code>isReset()</code> is false, <code>retryOnMount</code> becomes <code>false</code> and the refetch is turned off. A <code>useQuery</code> that does not throw is unaffected and simply refetches when it remounts.</p>
<p>That said, turning off <code>retryOnMount</code> only has an effect on a query that has never had data in the cache, that is, a query that failed on its first load. A query that had data gets <code>isInvalidated</code> set to true when it fails, is treated as stale, and refetches on remount through <code>refetchOnMount</code>. The query errors this post talks about are first-load failures.</p>
<p>That does not mean the refetch stays off forever. While the fallback is showing, no component is watching that query, so it becomes an inactive query, and under <a href="https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults" target="_blank" rel="noopener noreferrer">TanStack Query's defaults</a> it is removed from the cache after 5 minutes. Press the button after that and there is no error in the cache, so it requests from scratch. That is why code that forgot <code>onReset</code> can seem to work when you press it much later. If your repro is inconsistent, first check whether you pressed before or after <code>gcTime</code>.</p>
<p><picture><source type="image/webp" srcset="/content/251128/1.720w.webp 720w, /content/251128/1.1440w.webp 1440w, /content/251128/1.2080w.webp 2080w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251128/1.png" alt="On top, the flow when onReset is not wired: retry click, ErrorBoundary released, remount, throws the cached error again, and from the last box a red arrow loops back to the first labelled as the same fallback. Below, the flow when onReset is wired: retry click, onReset and the reset() call, ErrorBoundary released and remount, refetch, connected in one direction by blue arrows" style="max-width:min(720px, 100%);height:auto;" width="2080" height="800" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><strong><code>retryOnMount</code> is turned off only for a query lifted into an <code>ErrorBoundary</code>, and that is why you have to clear the <code>ErrorBoundary</code> and the query together.</strong> What raises the flag that <code>isReset()</code> reads is <code>QueryErrorResetBoundary</code>. Open the source and the state is a single boolean.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	isReset </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">},</span></span></code></pre></figure>
<p>If the flag were only ever raised and nobody lowered it, later errors would not turn off <code>retryOnMount</code> either, and every one would request again. The one that lowers it is the query hook. The remounted hook reads the flag during render and does not turn off <code>retryOnMount</code>. <code>getHasError</code>, which looks at the same flag, does not throw the cached error either. Then, after it is attached to the screen, it lowers the flag in an effect. The function lives in the same <code>errorBoundaryUtils.js</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useClearResetErrorBoundary</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">errorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">		errorResetBoundary.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">clearReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">	}, [errorResetBoundary]);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>So a single boolean is enough to mean retry just this once. In order, one retry goes like this:</p>
<ol>
<li><code>reset()</code> raises the <code>isReset</code> flag.</li>
<li>The remounted query hook sees the flag during render and does not turn off <code>retryOnMount</code>.</li>
<li>With the flag raised, the hook sees a pending state instead of the cached error and requests again (<code>useSuspenseQuery</code> sends the request during render and suspends).</li>
<li>If the request succeeds and the component is attached to the screen, <code>clearReset()</code> in an effect lowers the flag; if it fails again, that request's <code>catch</code> lowers it.</li>
<li>With the flag lowered, later errors turn off <code>retryOnMount</code> again.</li>
</ol>
<p>You connect this <code>reset</code> to the <code>ErrorBoundary</code>'s <code>onReset</code>. TanStack Query's docs and source comments also show code that connects them this way as an example.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> QueryAsyncBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">pendingFallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Props</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryErrorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">reset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">          onReset</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{reset}</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">          fallbackRender</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{({ </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">            &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorFallback</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> error</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{error} </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onRetry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{resetErrorBoundary} /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        ></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">          &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fallback</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{pendingFallback}>{children}&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Suspense</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">ErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      )}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">QueryErrorResetBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Here is how <code>react-error-boundary</code> implements <code>resetErrorBoundary</code>. It is a built file so the names are down to one letter, but the structure still reads.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">resetErrorBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">e) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">didCatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">t</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.state;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  t </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.props.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">onReset</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">?.({ args: e, reason: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"imperative-api"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }), </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">this</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(d));</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Only when <code>didCatch</code> is true does it call <code>onReset</code> and set the state back to the initial value <code>d</code> (<code>didCatch: false</code>). <code>setState</code> renders after the handler finishes, so by the time the children remount, the flag that <code>reset()</code> raised is already up. That is why it is enough to wire <code>onReset</code> to <code>reset</code>.</p>
<p>Even without wrapping in <code>QueryErrorResetBoundary</code>, if you take <code>useQueryErrorResetBoundary()</code> and pass its <code>reset</code> to <code>onReset</code>, retry works. With no enclosing boundary, the hook returns a module-global default. The cost, as the <a href="https://tanstack.com/query/latest/docs/framework/react/guides/suspense" target="_blank" rel="noopener noreferrer">Suspense guide</a> notes, is that the reset applies globally and the whole app shares one <code>isReset</code> flag.</p>
<h3 id="the-render-error-reset-cannot-clear"><a class="anchor" href="#the-render-error-reset-cannot-clear">The render error reset cannot clear</a></h3>
<p>The second is when the server returns a 200 with a shape other than the one you expected, and the render that reads it throws a <code>TypeError</code>. The same <code>ErrorBoundary</code> received it and <code>onReset</code> is wired, yet retry does nothing.</p>
<p>What <code>reset</code> clears is <strong>a query in an error state</strong>. But this query succeeded. The server gave a 200 and the cache holds that value as normal data. What produced the error was the render that read it. So <code>reset</code> has nothing to clear, and the remounted component reads the same cached value during render and throws again on the same line. Because it throws before it is attached to the screen, it never gets to subscribe to the query. That is why it does not request again even with <code>staleTime</code> set to 0. This query too is removed from the cache once <code>gcTime</code> passes and is requested again, but as long as the server sends the same value it throws again on the same line.</p>
<p>The place to fix it is <strong><code>queryFn</code></strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">queryFn</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> res</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">postId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/comments`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Array.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">isArray</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(data.comments)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TypeError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'comments 가 배열이 아니다'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.comments</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">},</span></span></code></pre></figure>
<p>On a <code>fetch</code> response, <code>json()</code> returns <code>Promise&#x3C;any></code>, so a type you attach after it is a declaration, not a check, and the runtime check that verifies what the server sent has to be put in yourself. <strong>Lift that check up into <code>queryFn</code></strong> and the same failure becomes <strong>the query's error</strong>. It stays in the cache in an error state, <code>reset</code> clears it, and retry refetches.</p>
<p>If you put the runtime check off because the <code>ErrorBoundary</code> catches it anyway, you get a fallback that receives but cannot undo.</p>
<h3 id="the-lazy-only-a-reload-clears"><a class="anchor" href="#the-lazy-only-a-reload-clears">The lazy only a reload clears</a></h3>
<p>The third is a chunk load failure. This time neither <code>reset</code> nor <code>queryFn</code> is involved. What holds the state is <code>lazy</code> itself.</p>
<p>React's <code>lazyInitializer</code> writes the rejection into <code>payload</code> and from then on throws the same thing every time.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> payload._result;</span></span></code></pre></figure>
<p>It does not <code>import()</code> again. The <code>lazy()</code> call happened once at module top level, and that <code>payload</code> stays as it is for the life of the app. Release the <code>ErrorBoundary</code> and remount, and the same error comes back.</p>
<p>Then why not create a new <code>lazy</code> and call <code>import()</code> again? Until now the browser blocked that. The module map remembered the failed result and did not fetch the same URL again. An <a href="https://github.com/whatwg/html/pull/10327" target="_blank" rel="noopener noreferrer">HTML spec change</a> that changes this was merged on 2026-07-15. Engine status as checked on 2026-10-08: Firefox <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=2055211" target="_blank" rel="noopener noreferrer">shipped it in 155</a>, released 2026-09-01. WebKit <a href="https://bugs.webkit.org/show_bug.cgi?id=319492" target="_blank" rel="noopener noreferrer">landed it in main</a> on 2026-08-19, and the bug record does not name a stable Safari release that includes it. Chrome is still Proposed on <a href="https://chromestatus.com/feature/5214647044145152" target="_blank" rel="noopener noreferrer">chromestatus</a>. So in Chrome, a fresh <code>import()</code> returns the same failure.</p>
<p>This applies to builds that load chunks with the browser's native <code>import()</code>, such as Vite. The webpack runtime loads chunks with script tags and clears the record of a failed chunk, so calling it again through a new <code>lazy</code> requests it again.</p>
<p>Either way, a <code>lazy</code> you already created does not try again, so the default recovery from this failure is fetching the page again. Even once browsers do refetch, not every case clears. A chunk load failure can come from a dropped network, or, as the <a href="https://vite.dev/guide/build#load-error-handling" target="_blank" rel="noopener noreferrer">Vite docs</a> explain, from a new deployment deleting the old chunks. A deleted chunk is still missing when requested again, so in that case recovery is still a reload. Since you cannot pin down a single cause, the fallback text is better off suggesting a reload than asserting that a new version is out.</p>
<p>In short, a query error is cleared with <code>reset</code>, a render error by turning it into a query error in <code>queryFn</code> beforehand, and a chunk failure by reloading.</p>
<h2 id="wrapping-up"><a class="anchor" href="#wrapping-up">Wrapping up</a></h2>
<p>If a retry button does nothing, I hope you will check <strong>what threw and where its state is still held</strong> before looking at the button or the <code>ErrorBoundary</code>. Where you clear it depends on whether it is the query cache, the render that read what the server sent, or <code>lazy</code>.</p>
<p>Where on the screen to put <code>ErrorBoundary</code> and how many, and which failures should not get a retry button at all, are covered in <a href="/251203" target="_blank" rel="noopener noreferrer">Placing ErrorBoundary</a>.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/reference/QueryErrorResetBoundary" target="_blank" rel="noopener noreferrer">TanStack Query, QueryErrorResetBoundary</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>error-handling</category>
        </item>
        <item>
            <title><![CDATA[Error Propagation]]></title>
            <link>https://hooninedev.com/en/251117</link>
            <guid isPermaLink="false">https://hooninedev.com/en/251117</guid>
            <pubDate>Mon, 17 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about how far an error climbs in the frontend. Drawing ErrorBoundary is easy. Put one on the route, wrap every screen in an ErrorBoundary, and the diagram has no gaps. The...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>how far an error climbs in the frontend</strong>.</p>
<p>Drawing <code>ErrorBoundary</code> is easy. Put one on the route, wrap every screen in an <code>ErrorBoundary</code>, and the diagram has no gaps. The hard part is knowing what that <code>ErrorBoundary</code> <strong>actually receives</strong>.</p>
<p>The word "propagation" in the title means <strong>how far a thrown error climbs and who ends up receiving it</strong>. The place it is thrown and the place it is received are not always the same, which is why they have to be counted separately.</p>
<p>So I counted. I threw the same <code>new Error('boom')</code> seven times, changing only the place, and watched whether it reached the <code>ErrorBoundary</code> or went past the <code>ErrorBoundary</code> to somewhere else.</p>
<p>The widget below is that experiment. Pick a place to throw on the left and the error is actually thrown inside the dashed box. The dashed box is the <code>ErrorBoundary</code>, and when the <code>ErrorBoundary</code> receives it the inside turns into a fallback. What is not received looks like nothing happened at all, so the widget also listens to <code>window</code> and its <code>error</code> and <code>unhandledrejection</code> events and writes below where it went.</p>
<div class="interactive-widget" data-widget="error-propagation"></div>
<p>Only four of the seven reach the <code>ErrorBoundary</code>. The other three are thrown inside the <code>ErrorBoundary</code> and still pass straight through the <code>ErrorBoundary</code> out to the global scope.</p>
<p>This post checks why that split happens, one kind of error at a time: the ones that end at compile time, the ones from render and lifecycle, the ones that come from server data, the ones from navigation, the ones third-party libraries throw, and the ones from events and async code. The point is to find out where each of them propagates to. <strong>How many layers to split the receiving side into, and how to recover, are not covered here.</strong> Drawing layers without knowing the propagation paths leaves those layers empty, which is why the order is this way.</p>
<p>I checked in two ways. Library behavior was read from the installed sources rather than from memory, and anything that could be confirmed by throwing was actually run in the widget above.</p>
<h2 id="errorboundary"><a class="anchor" href="#errorboundary">ErrorBoundary</a></h2>
<p>First, let me place <code>ErrorBoundary</code> precisely. An <code>ErrorBoundary</code> is <strong>two lifecycle methods of a class component</strong>. It simply goes by another name.</p>
<p><code>react-error-boundary</code> has only two implementations.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">static </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getDerivedStateFromError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">componentDidCatch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(e, t) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span></code></pre></figure>
<p>The official React docs separate when the two methods run. <code>getDerivedStateFromError</code> runs in the <strong>render phase</strong> and produces the state that draws the fallback, while <code>componentDidCatch</code> runs in the <strong>commit phase</strong> and takes care of side effects such as logging. Either way, they only receive <strong>what React caught inside the tree and handed over</strong>.</p>
<p>So there is a single definition of what an <code>ErrorBoundary</code> receives. <strong>Was it thrown from a place React can catch.</strong></p>
<p>The React docs also write down the side that is not received. These four are it.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Event handlers, server side rendering, errors thrown in the <code>ErrorBoundary</code> itself rather than in its children, asynchronous code such as <code>setTimeout</code> or <code>requestAnimationFrame</code> callbacks; there is one exception, the <code>useTransition</code> Hook's <code>startTransition</code> function. Errors thrown inside that transition function are caught by error boundaries.</p></div><div class="quote-original" lang="en"><p>Event handlers, Server side rendering, Errors thrown in the error boundary itself (rather than its children), Asynchronous code (e.g. <code>setTimeout</code> or <code>requestAnimationFrame</code> callbacks); an exception is the usage of the <code>startTransition</code> function returned by the <code>useTransition</code> Hook. Errors thrown inside the transition function are caught by error boundaries</p></div></blockquote>
<p>The widget results match that sentence exactly. Seven places split into two destinations.</p>
<p><picture><source type="image/webp" srcset="/content/251117/1.720w.webp 720w, /content/251117/1.1440w.webp 1440w, /content/251117/1.1760w.webp 1760w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/1.png" alt="Seven throw sites are stacked on the left and two destinations sit on the right. During render, inside useEffect, inside startTransition and a rejected lazy import go to ErrorBoundary along blue arrows, while inside an onClick handler, inside a setTimeout callback and a rejected Promise go to window along gray arrows" style="max-width:min(720px, 100%);height:auto;" width="1760" height="940" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><code>startTransition</code> is the exception because the work inside it goes through React's scheduler. React wraps that execution with its own hands, so it can catch it and route it back into the tree. For the same reason <code>setTimeout</code> cannot be caught. By the time that callback runs, React is no longer there.</p>
<p>There is one thing to know first. <strong>You cannot build an <code>ErrorBoundary</code> out of a function component.</strong></p>
<p>When React meets a thrown error it climbs from that spot toward the parent, looking for a boundary to receive it. <code>throwException</code>, which owns that walk, reads the <code>tag</code> attached to each fiber, and the only values it stops at are class component and root. A function component's <code>tag</code> is neither, so the search walks straight past it. <strong>It fails to be a boundary not because the API is missing but because the search never looks at it.</strong></p>
<p><picture><source type="image/webp" srcset="/content/251117/2.720w.webp 720w, /content/251117/2.1440w.webp 1440w, /content/251117/2.2016w.webp 2016w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/2.png" alt="Four boxes sit in a row. From the left, the function components Child and FnBoundary are tag 0, the class component ErrorBoundary is tag 1, and the root HostRoot is tag 3. An arrow starting at Child passes FnBoundary and stops at ErrorBoundary, and the line continuing to HostRoot is dashed" style="max-width:min(720px, 100%);height:auto;" width="2016" height="424" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>That <code>tag</code> is decided by whether the component inherits from <code>React.Component</code>. So attaching <code>getDerivedStateFromError</code> to a function as a <code>static</code> does nothing. I attached it and ran it, and the function was never called once; the error found no boundary, climbed to the root, and React removed the tree from the screen.</p>
<p>So installing <code>react-error-boundary</code> is not adding a feature that does not exist. The <code>ErrorBoundary</code> in 6.1.6 is also a class that inherits from <code>Component</code> and carries the same two methods. What the library does is write that class once and hide it.</p>
<h2 id="what-ends-at-compile-time"><a class="anchor" href="#what-ends-at-compile-time">What ends at compile time</a></h2>
<p>While counting kinds, let me start with the earliest one. Type errors.</p>
<p>This is the only kind that never reaches the user. Reading a property that does not exist or passing an argument whose type does not match blocks the build, and blocked code is not deployed. So there is no propagation path to trace. <strong>Type errors are left out of this post not because they do not matter but because they do not exist at runtime.</strong></p>
<p>The problem is what comes next. <strong>How far</strong> does type checking guarantee?</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">async</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getComments</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">postId</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Promise</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Comment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> res</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">`/api/posts/${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">postId</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}/comments`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> data</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> res.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> data.comments</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The return type says <code>Promise&#x3C;Comment[]></code>, so every caller of this function believes it receives an array. But <code>Response</code> and its <code>json()</code> are declared like this in TypeScript 5.9.3's <code>lib.dom.d.ts</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">json</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(): </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">Promise</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">any</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p><code>any</code>. Type checking stops here. The compiler has never seen the value the server returned, and the type argument or return type annotation written after it is <strong>a declaration, not a check</strong>. Nobody inserts code that verifies that declaration at runtime.</p>
<p>So when the server returns <code>200</code> with <code>{ commits: null }</code>, the line that reads <code>commits.length</code> throws a <code>TypeError</code> during render. HTTP succeeded and the types passed, yet the screen breaks.</p>
<p><strong>Where types end is where a runtime check belongs.</strong> Where that check goes decides which path the same failure takes. If it blows up while reading during render it becomes a render error and goes to the <code>ErrorBoundary</code>, and if you check where the data arrives and throw there it becomes that request's failure. The <strong>Render and lifecycle</strong> section below covers that.</p>
<h2 id="render-and-lifecycle"><a class="anchor" href="#render-and-lifecycle">Render and lifecycle</a></h2>
<p>Anything thrown inside the React tree propagates simply. <strong>The nearest <code>ErrorBoundary</code> receives it.</strong></p>
<p>Exceptions during render belong here. The <code>commits.length</code> above is one, and calling <code>map</code> on a value you thought was an array is another. This group has nothing to do but throw, so it always reaches an <code>ErrorBoundary</code>.</p>
<p>Something thrown inside <code>useEffect</code> is caught too. React runs effects itself after commit, so it can wrap that execution. But an <strong>async callback called inside</strong> an effect is different.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'boom'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)          </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ErrorBoundary 가 받는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [])</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useEffect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  setTimeout</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'boom'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)        </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ErrorBoundary 를 지나친다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [])</span></span></code></pre></figure>
<p>The two snippets sit inside the same <code>useEffect</code>, yet their propagation paths differ. <strong>The test is not whether it is inside an <code>ErrorBoundary</code> but whether React is holding on to that execution.</strong></p>
<p>There is one more thing to remember about this group. React 19's development console attaches the component name to a caught error. When I ran the widget, render and effect and transition all said <code>The above error occurred in the &#x3C;Thrower> component</code>, and only <code>lazy</code> said <code>occurred in one of your React components</code>. <code>lazy</code> has no component yet at the moment it rejects, so it cannot write a name. That difference becomes a clue when you have only a stack to find the spot with.</p>
<h2 id="server-data"><a class="anchor" href="#server-data">Server data</a></h2>
<p>This is where the tricky part of frontend work begins. The reason is that a server failure <strong>does not automatically become an error</strong>.</p>
<h3 id="fetch-does-not-reject-on-server-errors-by-itself"><a class="anchor" href="#fetch-does-not-reject-on-server-errors-by-itself">fetch does not reject on server errors by itself</a></h3>
<p>MDN writes this down clearly.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>A <code>fetch()</code> promise only rejects when the request itself fails, for example because the request URL is badly formed or a network error occurred. It does not reject when the server responds with an HTTP status code that indicates an error, such as <code>404</code> or <code>504</code>.</p></div><div class="quote-original" lang="en"><p>A <code>fetch()</code> promise only rejects when the request fails, for example, because of a badly-formed request URL or a network error. A <code>fetch()</code> promise does not reject if the server responds with HTTP status codes that indicate errors (<code>404</code>, <code>504</code>, etc.).</p></div></blockquote>
<p>So if you only use <code>fetch</code>, a <code>500</code> response is a <strong>successful Promise</strong>. Nothing was thrown, so the <code>ErrorBoundary</code> does not know and neither does the data library. The TanStack Query docs make the same point. For a query to be judged as failed the <code>queryFn</code> has to throw or return a rejected Promise, and while <code>axios</code> throws on its own, <code>fetch</code> does not.</p>
<p>So turning a server failure into an error is <strong>a job you have to do yourself</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> response</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> await</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> fetch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'/todos/'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> todoId)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">response.ok) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Network response was not ok'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Without these three lines the rest of this section means nothing. What is not thrown propagates nowhere.</p>
<h3 id="five-ways-one-failure-splits"><a class="anchor" href="#five-ways-one-failure-splits">Five ways one failure splits</a></h3>
<p>Once a failure becomes a failure, the next split begins. The same single <code>500</code> scatters to five places depending on <strong>how it was called</strong>. This is with the defaults, no options touched.</p>
<p><strong><code>useQuery</code> does not throw.</strong> Open <code>useQuery.js</code> and the string <code>throwOnError</code> is not there at all. Whether to throw is decided by <code>query-core</code> and its <code>shouldThrowError</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldThrowError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">throwOnError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">params</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> throwOnError </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> "function"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> throwOnError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">params);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">	return</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">throwOnError;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>With no value it is <code>!!undefined</code>, so <code>false</code>. The failure only lands in <code>query.error</code> and the component renders normally. The outer <code>ErrorBoundary</code> never learns that its turn came.</p>
<p><strong><code>useSuspenseQuery</code> throws, but not always.</strong> This hook spreads the options and then overwrites <code>throwOnError</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBaseQuery</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  ...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">options,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  enabled: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  suspense: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  throwOnError: defaultThrowOnError,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  placeholderData: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">void</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, QueryObserver, queryClient);</span></span></code></pre></figure>
<p>The overwrite comes after <code>...options</code>, so <strong>a <code>throwOnError</code> passed by the caller is ignored.</strong> And the default decision that takes its place is one line in <code>suspense.js</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> defaultThrowOnError</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">_error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">query</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> query.state.data </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> void</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p><strong>If there is a cache to show, it does not throw.</strong> In practice the place this branch splits is a background refetch. A first-time visitor has an empty cache, so the failure goes to the <code>ErrorBoundary</code> and they see the fallback. A visitor already on the screen who leaves for another tab and comes back triggers a refetch, and if that fails the cache still holds the old data, so nothing is thrown. The screen keeps showing the stale value and does not change on its own.</p>
<p>Not breaking the screen is mostly good behavior. <strong>What comes along with it is that the screen does not tell you</strong>, and choosing that knowingly is different from being caught by it.</p>
<p><strong>Neither of the two functions a mutation returns goes to the <code>ErrorBoundary</code>.</strong> The reasons differ. Open <code>useMutation.js</code> and one side swallows the rejection directly.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">observer.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">mutate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(args[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">], args[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">catch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(noop);</span></span></code></pre></figure>
<p>This is <code>mutate</code>. In the same file <code>mutateAsync</code> exports <code>result.mutate</code> as is, and that <code>result.mutate</code> is what <code>mutationObserver.js</code> shipped with <code>mutate: this.mutate</code>, so it ends up being the same function the line above wrapped. <strong>Only one side goes through <code>.catch(noop)</code>.</strong> That rejection blows up where it is <code>await</code> ed rather than being thrown during render, so this side has nothing to do with the <code>ErrorBoundary</code> either.</p>
<p><strong>That does not mean a mutation is forever unrelated to the <code>ErrorBoundary</code>.</strong> There is one more switch in the hook body.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (result.error </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> shouldThrowError</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(observer.options.throwOnError, [result.error])) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> result.error;</span></span></code></pre></figure>
<p>Give it <code>throwOnError</code> and this line throws <strong>during render</strong>, and then it goes to the <code>ErrorBoundary</code>. The two returned functions not reaching the <code>ErrorBoundary</code> and the hook not throwing are two different stories.</p>
<p><picture><source type="image/webp" srcset="/content/251117/3.720w.webp 720w, /content/251117/3.1440w.webp 1440w, /content/251117/3.2080w.webp 2080w" sizes="(max-width: 752px) calc(100vw - 32px), 720px"><img src="/content/251117/3.png" alt="From a single server 500 on the left, five arrows fan out to useQuery, useSuspenseQuery, a hook with throwOnError on, mutate and mutateAsync, and each of those leads on to query.error, ErrorBoundary, ErrorBoundary, mutation.error and the caller&#x27;s catch. Only the two middle ErrorBoundary boxes are tied together with a dashed line, marked as the two that ErrorBoundary receives" style="max-width:min(720px, 100%);height:auto;" width="2080" height="800" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>To sum up, the same <code>500</code> has five destinations. The <code>query.error</code> field, the <code>mutation.error</code> field, the caller's <code>catch</code>, and the two cases that go to the <code>ErrorBoundary</code>. The two that go to the <code>ErrorBoundary</code> are <code>useSuspenseQuery</code> failing with no cache, and <code>throwOnError</code> being turned on. <strong>The calling style, not the kind of failure, decides the destination.</strong></p>
<h2 id="navigation"><a class="anchor" href="#navigation">Navigation</a></h2>
<p>Failures that happen while moving between screens split in two. The dividing line is <strong>inside or outside the React tree</strong>.</p>
<h3 id="loader-runs-outside-the-tree"><a class="anchor" href="#loader-runs-outside-the-tree">loader runs outside the tree</a></h3>
<p>A router <code>loader</code> is a function that runs before rendering starts. It is not a React component, so neither <code>getDerivedStateFromError</code> nor <code>componentDidCatch</code> reaches it. No matter how much you wrap things with <code>react-error-boundary</code>, that <code>ErrorBoundary</code> cannot see a loader failure.</p>
<p>Instead the router keeps its own <code>ErrorBoundary</code> system. The React Router docs put it this way.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Route modules automatically catch errors in your code and render the closest <code>ErrorBoundary</code>.</p></div><div class="quote-original" lang="en"><p>route modules will automatically catch errors in your code and render the closest <code>ErrorBoundary</code>.</p></div></blockquote>
<p>How the closest one is picked is in the source. <code>findNearestBoundary</code> picks like this.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> findNearestBoundary</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">matches</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">routeId</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">slice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, matches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">findIndex</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.id </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> routeId) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">matches];</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> eligibleMatches.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">reverse</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">().</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">find</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">m</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> m.route.hasErrorBoundary </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> true</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> matches[</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>It scans the matched routes from the back, picks the first route that has an <code>ErrorBoundary</code>, and sends the error to the frontmost route when there is none. <strong>So putting one more <code>ErrorBoundary</code> on a lower route is not duplication, it narrows the area the fallback is drawn over.</strong></p>
<p>The reading side differs too. A route <code>ErrorBoundary</code> does not receive the error as props, it pulls it out directly with <code>useRouteError()</code>. Whether a status code came with it is decided by <code>isRouteErrorResponse(error)</code>. Neither of these exists on React's <code>ErrorBoundary</code>.</p>
<h3 id="a-lazy-rejection-is-inside-the-tree"><a class="anchor" href="#a-lazy-rejection-is-inside-the-tree">A lazy rejection is inside the tree</a></h3>
<p>Within the same navigation, the side that receives code late is the opposite. When <code>lazy(() => import('./Tab'))</code> has its <code>import()</code> rejected, React takes it and throws it to the <strong>nearest <code>ErrorBoundary</code></strong>. The fourth button in the widget is this path.</p>
<p>When a deploy ships, the old chunk files disappear, while a screen opened before the deploy still holds the old addresses. Ask for that code in that state and <code>import()</code> rejects, and Chrome throws <code>TypeError: Failed to fetch dynamically imported module</code>.</p>
<p>One more thing attaches here. <strong><code>lazy</code> remembers the rejection.</strong> React's <code>lazyInitializer</code> writes the result into <code>payload</code>, and on rejection it changes the status and stores the reason.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">payload._status </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">payload._result </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> error;</span></span></code></pre></figure>
<p>From then on, every time this component renders, the last branch runs.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> payload._result;</span></span></code></pre></figure>
<p>It does not <code>import()</code> again. The <code>lazy()</code> call happened once at module top level and that <code>payload</code> stays for as long as the app lives. <strong>Resetting the <code>ErrorBoundary</code> and remounting brings the same error back.</strong> That is why recovering from this failure means loading the page again.</p>
<p>Within the same navigation, a loader failure is received by the router and a <code>lazy</code> failure by React. <strong>Unless you separate these two before deciding where to put an <code>ErrorBoundary</code>, one of them has nowhere to go.</strong></p>
<h2 id="global-handlers-outside-errorboundary"><a class="anchor" href="#global-handlers-outside-errorboundary">Global handlers outside ErrorBoundary</a></h2>
<p>Where did the three the <code>ErrorBoundary</code> failed to catch go? To <strong>the browser's global handlers</strong>.</p>
<p>What is thrown from an event handler or a <code>setTimeout</code> callback ends up at <code>window</code> and its <code>error</code> event. A Promise rejection goes to a different event. MDN defines it this way.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>The <code>unhandledrejection</code> event is sent to the global scope of a script when a JavaScript Promise that has no rejection handler is rejected. Typically this is the <code>window</code>, but it may also be a <code>Worker</code>.</p></div><div class="quote-original" lang="en"><p>The <code>unhandledrejection</code> event is sent to the global scope of a script when a JavaScript Promise that has no rejection handler is rejected; typically, this is the <code>window</code>, but may also be a <code>Worker</code>.</p></div></blockquote>
<p>These two are the last places the browser receives anything. They are also where most error monitoring tools catch browser errors.</p>
<p>React 19 added one more receiving spot on the tree side as well. It is an option on <code>createRoot</code>. The official docs separate the three like this.</p>
<table>
<thead>
<tr>
<th>Option</th>
<th>When it is called</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>onCaughtError</code></td>
<td>When React caught an error inside an Error Boundary</td>
</tr>
<tr>
<td><code>onUncaughtError</code></td>
<td>When an error was thrown and no Error Boundary caught it</td>
</tr>
<tr>
<td><code>onRecoverableError</code></td>
<td>When React recovered on its own</td>
</tr>
</tbody>
</table>
<p>Receiving and fixing are different. <strong>A global handler catching something does not restore the screen.</strong> Even when an error thrown inside <code>onClick</code> is received by <code>window</code> and sent to a monitoring tool, at that moment the user simply sees a button that did not respond. Reporting and recovering are different jobs.</p>
<h2 id="wrapping-up"><a class="anchor" href="#wrapping-up">Wrapping up</a></h2>
<p>Memorizing frontend error handling as a list of tools keeps leaving holes. It happens even after you know <code>ErrorBoundary</code>, <code>throwOnError</code>, <code>useRouteError</code>, <code>lazy</code> and <code>unhandledrejection</code>. Not because the tools are unknown, but because <strong>what goes where was never counted</strong>.</p>
<p>Here is what this post covered.</p>
<ul>
<li>An <code>ErrorBoundary</code> only receives what React caught and handed over. Event handlers and async callbacks are not in that spot.</li>
<li>Type checking ends at the response. From the point <code>json()</code> returns <code>any</code>, it is a declaration, not a check.</li>
<li>A server failure does not become an error by itself. <code>fetch</code> does not reject on <code>500</code>, so throwing is a job you do yourself.</li>
<li>Once thrown, the calling style decides the destination. The same failure goes to a field, to the caller, or to an <code>ErrorBoundary</code>.</li>
<li>Navigation splits in two. A loader is outside the tree so the router receives it, and <code>lazy</code> is inside the tree so React does.</li>
<li>Outside the <code>ErrorBoundary</code> there are global handlers. Things are caught, but the screen is not restored.</li>
</ul>
<p>So the job before drawing an <code>ErrorBoundary</code> is not choosing a component to wrap. It is <strong>writing down every place this screen can fail and marking which of the six above each one belongs to</strong>. An unmarked place is a hole.</p>
<p>It would be good to find out how many places your own screen can fail in, how many of those reach an <code>ErrorBoundary</code>, and where the ones that do not are going.</p>
<p>What receives each destination is covered in <a href="/251203" target="_blank" rel="noopener noreferrer">Placing ErrorBoundary</a>. How many layers to split into, and how to handle the screen area one failure takes. What else has to be reset for the retry button in a fallback to actually retry is covered separately in <a href="/251128" target="_blank" rel="noopener noreferrer">Why the Retry Button Does Nothing</a>.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary" target="_blank" rel="noopener noreferrer">React, Error Boundary in Component</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react-dom/client/createRoot" target="_blank" rel="noopener noreferrer">React, error callbacks on createRoot</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/lazy" target="_blank" rel="noopener noreferrer">React, lazy</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://reactrouter.com/how-to/error-boundary" target="_blank" rel="noopener noreferrer">React Router, Error Boundaries</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-functions" target="_blank" rel="noopener noreferrer">TanStack Query, Query Functions</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/unhandledrejection_event" target="_blank" rel="noopener noreferrer">MDN, unhandledrejection</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch" target="_blank" rel="noopener noreferrer">MDN, fetch</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://axios.rest/pages/advanced/error-handling" target="_blank" rel="noopener noreferrer">axios, Error handling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/bvaughn/react-error-boundary" target="_blank" rel="noopener noreferrer">bvaughn/react-error-boundary</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
            <category>TanStack-Query</category>
            <category>error-handling</category>
        </item>
        <item>
            <title><![CDATA[Mastering React Fiber]]></title>
            <link>https://hooninedev.com/en/250520</link>
            <guid isPermaLink="false">https://hooninedev.com/en/250520</guid>
            <pubDate>Tue, 20 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about the Fiber architecture, which could be called the heart of React. This is for frontend developers who know the one-line definition, "it divides work into units," but...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about the <strong>Fiber architecture</strong>, which could be called the heart of React.</p>
<p>This is for frontend developers who know the one-line definition, "it divides work into units," but find it hard to explain how React actually implements it. By the end, you will be able to explain, with React's source code as evidence, why Fiber appeared, what a Fiber node looks like, and how double buffering, Lanes, and the Work Loop fit together to make Concurrent Features possible.</p>
<p>When I first encountered React, I thought of the word <strong>"Fiber"</strong> as little more than a common interview question. I memorized a one-line definition—"it divides rendering into units of work and processes them"—and assumed that was the whole story. But once I began looking through React's actual source code, I realized that Fiber is not merely a concept. It is the runtime architecture that governs <strong>everything</strong> about React rendering.</p>
<h2 id="why-was-fiber-introduced"><a class="anchor" href="#why-was-fiber-introduced">Why Was Fiber Introduced?</a></h2>
<p>To answer this question, we first need to understand the problems in the world before Fiber: the <strong>Stack Reconciler</strong>, which React used through version 15.</p>
<p>As its name suggests, the Stack Reconciler was a reconciliation engine based on <strong>recursive calls</strong>. It traversed the component tree recursively from top to bottom, and once rendering began, it could not stop until it had processed the entire tree. It was like being unable to hang up a phone call until the other person had finished speaking. (Imagine that they start a three-hour counseling session about their life and you cannot interrupt. Terrifying.)</p>
<p>More specifically, the Stack Reconciler had the following limitations.</p>
<ul>
<li><strong>Rendering could not be interrupted</strong>: Because the entire tree had to be processed at once, the main thread could be occupied for tens or hundreds of milliseconds in a complex UI</li>
<li><strong>No concept of priority</strong>: Whether a user clicked a button or background data was updated, every update was processed in the same way</li>
<li><strong>Poor handling of animations and gestures</strong>: Maintaining 60 fps requires all work to finish within roughly 16 ms per frame, something recursive rendering could not guarantee</li>
<li><strong>One error could halt the entire app</strong>: An error anywhere in the component tree could stop the whole application</li>
</ul>
<p>To overcome these limitations, the React team considered a new execution model that could <strong>split up</strong> work, <strong>assign priorities</strong>, and <strong>pause and resume</strong> when necessary. The result was <strong>React Fiber</strong>.</p>
<p>Andrew Clark's <a href="https://github.com/acdlite/react-fiber-architecture" target="_blank" rel="noopener noreferrer">react-fiber-architecture</a> document captures the core ideas behind this design and is the most important reference for understanding Fiber. (It appears that he joined the React team not long after writing it.)</p>
<h2 id="stack-vs-fiber"><a class="anchor" href="#stack-vs-fiber">Stack vs Fiber</a></h2>
<p>So how do the Stack Reconciler and Fiber Reconciler differ at the code level?</p>
<h3 id="the-recursion-based-stack-reconciler"><a class="anchor" href="#the-recursion-based-stack-reconciler">The Recursion-Based Stack Reconciler</a></h3>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> renderComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">component</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> element</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> component.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">render</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  element.props.children.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">forEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">child</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> renderComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(child)); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 재귀 호출</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>With the Stack approach, encountering a child component leads <strong>immediately into a recursive call</strong>. The problem is that this approach depends directly on JavaScript's call stack. As recursive calls deepen, frames accumulate on the call stack, and the browser's main thread cannot do anything else until every one of those frames has been resolved.</p>
<p>Simply put, the browser is <strong>completely unable to move</strong> until the call stack is empty.</p>
<video width="640" height="480" controls>
  <source src="/content/250520/stack.mov" type="video/mp4">
</video>
<p>The video above shows the main thread being completely blocked while the Stack Reconciler renders.</p>
<h3 id="the-iteration-based-fiber-reconciler"><a class="anchor" href="#the-iteration-based-fiber-reconciler">The Iteration-Based Fiber Reconciler</a></h3>
<p>Fiber replaced recursion with an <strong>iterative loop</strong>. In place of the call stack, it implements its own <strong>virtual stack</strong> in memory. Each Fiber node is effectively a "stack frame," and because these nodes exist as JavaScript objects in heap memory, work can be paused at any point and resumed later.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">deadline</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (nextUnitOfWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> deadline.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">timeRemaining</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    nextUnitOfWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(nextUnitOfWork);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  requestIdleCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWork); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 나눠서 실행</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The code above illustrates Fiber's early conceptual model. The key is that the <code>while</code> loop processes only one unit of work at a time and, when time runs short, exits the loop and returns control to the browser.</p>
<p>That example, however, uses <code>requestIdleCallback</code> only to illustrate the concept; React does not actually use it. This API is called only during idle periods that the browser decides on, so in my view, on a page whose main thread is busy, React's work can keep getting pushed back. React's Scheduler package therefore schedules the next macrotask with <code>MessageChannel</code> to continue its work, returning control to the main thread in between. Why it passed over <code>setTimeout</code> in favor of <code>MessageChannel</code> is covered separately in <a href="/250515" target="_blank" rel="noopener noreferrer">Why React Uses MessageChannel</a>.</p>
<video width="640" height="480" controls>
  <source src="/content/250520/fiber.mov" type="video/mp4">
</video>
<p>With Fiber, React can respond immediately to user events such as button clicks and typing even during rendering. Breaking work into small pieces gives the browser room to breathe.</p>
<p>If you want to experience the difference firsthand, click <strong><a href="https://animated-lollipop-2b6cbb.netlify.app/" target="_blank" rel="noopener noreferrer">here</a></strong>. You can see how the Stack Reconciler and Fiber Reconciler behave differently.</p>
<p>These are precisely the core goals of Fiber that Andrew Clark emphasized in his document.</p>
<ul>
<li><strong>Pause work and return to it later</strong></li>
<li><strong>Assign priority to different types of work</strong></li>
<li><strong>Reuse previously completed work</strong></li>
<li><strong>Abort work that is no longer needed</strong></li>
</ul>
<h2 id="inside-a-fiber-node"><a class="anchor" href="#inside-a-fiber-node">Inside a Fiber Node</a></h2>
<p>At this point, one question naturally comes to mind: "So what does a Fiber node look like internally?"</p>
<p>The React team does not provide separate official documentation for Fiber's internal implementation. However, its structure can be understood through Andrew Clark's react-fiber-architecture document and React's actual source code (<code>ReactFiber.js</code>).</p>
<p>I like to compare a Fiber node to a <strong>work order</strong>. When a product is assembled in a factory, each work order specifies what kind of part it is, what materials it uses, which task comes next, and what its priority is. A Fiber node works in much the same way.</p>
<h3 id="reactelement-and-fibernode"><a class="anchor" href="#reactelement-and-fibernode">ReactElement and FiberNode</a></h3>
<p>To understand Fiber, you must first distinguish <strong>ReactElement</strong> from <strong>FiberNode</strong>. They are often confused, but they are completely different things.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="ts" data-theme="github-dark github-light"><code data-language="ts" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// ReactElement — React.createElement()가 반환하는 가벼운 객체</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> interface</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReactElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  type</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Function</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 문자열(HTML 태그) 또는 함수(컴포넌트)</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  props</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    [</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">    children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReactElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  key</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  ref</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  _owner</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> FiberNode</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>A ReactElement is only a <strong>blueprint</strong> for the UI. It is a request saying, "Render this component with these props," and contains no actual rendering logic or state.</p>
<p>By contrast, a <strong>FiberNode</strong> is the <strong>runtime unit of work</strong> that React creates internally from that blueprint. It contains fields that do not exist on a ReactElement, including <code>tag</code>, <code>stateNode</code>, <code>child/sibling/return</code>, <code>memoizedState</code>, <code>updateQueue</code>, and <code>lanes</code>.</p>
<p>When React examines a ReactElement's <code>type</code> and creates a FiberNode, it determines the <strong>tag</strong> value.</p>
<ul>
<li>If <code>type</code> is a function with <code>prototype.isReactComponent</code> → <code>tag = ClassComponent(1)</code></li>
<li>If <code>type</code> is a function → <code>tag = FunctionComponent(0)</code></li>
<li>If <code>type</code> is a string (such as <code>"div"</code>) → <code>tag = HostComponent(5)</code></li>
</ul>
<p><strong>tag</strong> is a numeric constant indicating the kind of FiberNode. It is defined in <code>ReactWorkTags.js</code>, and there are more than 25 tags, including <code>FunctionComponent(0)</code>, <code>ClassComponent(1)</code>, <code>HostRoot(3)</code>, <code>HostComponent(5)</code>, and <code>HostText(6)</code>. React uses this tag value in <code>beginWork</code> to decide which processing logic to run.</p>
<p><strong>type</strong> plays a central role in reconciliation. When React compares a Fiber from the previous render with a new element, type is the <strong>very first thing it checks</strong>. (The value is passed directly from the ReactElement to the FiberNode.)</p>
<ul>
<li>If it was a <code>div</code> before and is still a <code>div</code>, React <strong>reuses</strong> that Fiber node and updates only its props</li>
<li>If it was a <code>div</code> before but has changed to a <code>span</code>, React <strong>discards</strong> the old Fiber and creates a new one</li>
</ul>
<p><strong>key</strong> is also passed from the ReactElement to the FiberNode and is used primarily when rendering lists (arrays). Without a key, React cannot accurately determine where each item moved when the order of list items changes. This can cause unnecessary DOM operations or unintentionally preserve or lose a component's internal state.</p>
<h3 id="child-sibling-return"><a class="anchor" href="#child-sibling-return">child, sibling, return</a></h3>
<p>This is the secret that lets React Fiber use iteration instead of recursion.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 부모</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">자식1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">/>, &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">자식2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">/>];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><strong>child</strong> points to the <strong>first</strong> child element returned by the component's render. In the example above, that is <code>&#x3C;자식1/></code>. <strong>sibling</strong> means the <strong>next sibling</strong> with the same parent. The sibling of <code>&#x3C;자식1/></code> is <code>&#x3C;자식2/></code>. <strong>return</strong> points to the parent Fiber to <strong>return to</strong> once the current Fiber node has been processed. The return value of both <code>&#x3C;자식1/></code> and <code>&#x3C;자식2/></code> is <code>부모</code>.</p>
<p>The structure formed by these three fields is a <strong>tree represented as a singly linked list</strong>. In a conventional tree, keeping a child array (<code>children[]</code>) feels intuitive, but Fiber deliberately avoids doing so.</p>
<p>Why? An array-based child structure requires an index for traversal, and after pausing and resuming, React would need to track separately how far it had progressed. With a linked list structure, remembering only the current node reference is enough to resume traversal at any time. This is the structural foundation that allows Fiber to support <strong>pausing and resuming</strong> naturally.</p>
<p>React traverses nodes in depth-first search (DFS) order using this structure. It descends along <code>child</code> (beginWork); once it reaches a leaf node, it checks <code>sibling</code>; and when there is no sibling, it ascends along <code>return</code> (completeWork).</p>
<h3 id="pendingprops-and-memoizedprops"><a class="anchor" href="#pendingprops-and-memoizedprops">pendingProps and memoizedProps</a></h3>
<p><strong>pendingProps</strong> means the <strong>new props</strong> passed to a Fiber when its processing begins, while <strong>memoizedProps</strong> represents the <strong>previous props</strong> whose processing was completed in the prior render.</p>
<p>If these two values are identical, React can conclude that "nothing changed in this component" and reuse the previous rendering result. This is the core mechanism behind the <strong>bailout optimization</strong>.</p>
<p>Similarly, <strong>memoizedState</strong> stores the Fiber's hook state, while <strong>updateQueue</strong> manages pending state updates (calls to setState) as a linked list.</p>
<h3 id="statenode"><a class="anchor" href="#statenode">stateNode</a></h3>
<p><strong>stateNode</strong> references the <strong>actual instance</strong> represented by a Fiber node.</p>
<ul>
<li>For a <strong>HostComponent</strong> (div, span, and so on): the actual DOM node</li>
<li>For a <strong>ClassComponent</strong>: the class instance</li>
<li>For a <strong>HostRoot</strong>: the FiberRoot object</li>
</ul>
<p>This field serves as the bridge between Fiber's virtual world and the browser's actual DOM.</p>
<h2 id="double-buffering-the-current-and-workinprogress-trees"><a class="anchor" href="#double-buffering-the-current-and-workinprogress-trees">Double Buffering: The current and workInProgress Trees</a></h2>
<p>One essential concept that cannot be left out when discussing Fiber is <strong>double buffering</strong>.</p>
<p>Think about game graphics. If a game draws pixels directly onto the current screen, users may see a partially drawn frame, a visual artifact known as <strong>flicker</strong>. To prevent this, game engines use <strong>two buffers</strong>. They draw the next frame completely in one buffer and, once it is ready, swap the buffer displayed on screen all at once.</p>
<p>React Fiber uses exactly the same strategy.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">currentFiber.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgressFiber;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">workInProgressFiber.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> currentFiber;</span></span></code></pre></figure>
<p>The <strong>current tree</strong> is the Fiber tree currently reflected on screen. It represents the UI state the user is seeing, while the <strong>workInProgress tree</strong> is the Fiber tree being prepared in the background for the next render.</p>
<p>The two trees reference each other through the <code>alternate</code> property. All changes are made in the workInProgress tree, and when the work is complete, the trees are swapped with a single line: <code>root.current = finishedWork</code>. The previous workInProgress becomes the new current, and the previous current is recycled as workInProgress for the next render.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createWorkInProgress</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">current</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">pendingProps</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.alternate;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 최초 렌더: 새 Fiber를 생성하고 alternate를 연결</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createFiber</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current.tag, pendingProps, current.key, current.mode);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.stateNode </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.stateNode; </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// DOM 노드는 공유!</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    current.alternate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 재렌더: 기존 alternate를 재사용, effect만 초기화</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.pendingProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> pendingProps;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.flags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress.deletions </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // lanes, child, memoizedState 등을 복사</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  workInProgress.childLanes </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.childLanes;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  workInProgress.child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.child;</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // ...</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Here is the key point: <code>stateNode</code> (the actual DOM node) is <strong>shared</strong> between current and workInProgress. Rather than creating new Fiber objects on every render, React reuses the existing alternate and updates only the fields that changed. This allows it to construct the tree efficiently without adding garbage collection (GC) pressure on every render.</p>
<p>What if neither props nor state has changed? React can skip the entire subtree through the <strong>bailout optimization</strong>. If double buffering in games is a frame-level optimization, Fiber's double buffering enables optimization down to the <strong>component level</strong>.</p>
<h2 id="pendingworkpriority--lanes"><a class="anchor" href="#pendingworkpriority--lanes">pendingWorkPriority => Lanes</a></h2>
<p>How, then, does Fiber decide that one piece of work is more important than another?</p>
<h3 id="the-limits-of-expirationtime"><a class="anchor" href="#the-limits-of-expirationtime">The Limits of expirationTime</a></h3>
<p>Early Fiber used a numeric priority called <code>pendingWorkPriority</code>, which later evolved into a single number called <code>expirationTime</code>. A nearer expiration time meant higher priority, but this approach had a fundamental limitation.</p>
<p>A single number could not provide <strong>flexible grouping</strong> of the form "this update belongs to group A, while that update belongs to group B." For example, when user input and a Transition update occurred at the same time, the expirationTime-based approach could classify them only through range comparisons, limiting React's ability to selectively process particular updates.</p>
<h3 id="lane"><a class="anchor" href="#lane">Lane</a></h3>
<p>To solve this problem, Andrew Clark introduced the <strong>Lane system</strong> in <a href="https://github.com/facebook/react/pull/18796" target="_blank" rel="noopener noreferrer">PR #18796</a>.</p>
<p>To understand Lanes, picture a <strong>highway</strong>. A highway has multiple lanes, each serving a different purpose. The first lane is for passing (urgent), the second for normal travel, and the shoulder for emergencies. Each vehicle (update) is assigned to the lane appropriate to its nature, and the highway management system (scheduler) decides which lane's vehicles should pass first.</p>
<p>React's Lanes work the same way. Each update is assigned <strong>one bit (a lane)</strong>, and bitwise operations are used to create and compare groups.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 각 업데이트는 하나의 lane(단일 비트)을 가진다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SyncLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">             /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000000010</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> InputContinuousLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000001000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> DefaultLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">          /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000000100000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> TransitionLane1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0000000000000000000000100000000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> IdleLane</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">             /*  */</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0b0001000000000000000000000000000</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 배치(batch)는 여러 비트의 OR 조합이다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SyncUpdateLanes</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SyncLane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> InputContinuousLane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> DefaultLane;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 특정 lane이 batch에 포함되는지 확인은 단순 비트 연산</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> isIncluded</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (lane </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> lanes) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span></code></pre></figure>
<p>The system is designed so that 31 lanes fit into a 31-bit integer, taking advantage of the V8 engine's <strong>SMI (Small Integer)</strong> optimization. Integers of 31 bits or fewer are handled with pointer tagging in V8 and can be operated on directly on the stack without heap allocation. Among the major lanes, a <strong>lower bit means a higher priority</strong>.</p>
<p>Thanks to this structure, React can decide which work to process first with a single bitwise operation. The <code>getNextLanes()</code> function selects the highest-priority lane group from <code>pendingLanes</code>, skips suspended lanes, and gives priority to retrying pinged lanes whose data has arrived, enabling sophisticated scheduling.</p>
<p>Each lane is also assigned an expiration time to prevent <strong>starvation</strong>. Sync/InputContinuous is added to <code>expiredLanes</code> after 250 ms, and Transition after 5,000 ms, forcing synchronous processing. In other words, no matter how low its priority, work is never ignored forever. (If low-priority work were ignored forever, that would not be a priority system; it would be a discrimination system.)</p>
<h2 id="fibers-output"><a class="anchor" href="#fibers-output">Fiber's output</a></h2>
<p>Now that we have examined Fiber's structure, another question arises: how do these Fiber nodes turn into the <strong>actual DOM</strong>?</p>
<p>The output represents concrete DOM node information that can be applied to the actual DOM. There is an important distinction here.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용자 정의 컴포넌트 — output 없음</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 아바타</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">img</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> src</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"profile.jpg"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트 컴포넌트 — output 생성</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">img</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> src</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"profile.jpg"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"프로필"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span></code></pre></figure>
<p>Only <strong>host components</strong> (div, span, img, and so on) create actual DOM nodes. The browser has no idea what <code>&#x3C;아바타/></code> is. A user-defined component is an abstraction, so it must ultimately be broken down into host components before the browser can understand it.</p>
<p>Let us examine this process in more detail.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="jsx" data-theme="github-dark github-light"><code data-language="jsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 프로필</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> className</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"프로필"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">아바타</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">유저정보</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 아바타</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">img</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> src</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"profile.jpg"</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> alt</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"프로필"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> 유저정보</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>홍길동&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">h2</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      &#x3C;</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>개발자&#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">p</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    &#x3C;/</span><span style="--shiki-dark:#85E89D;--shiki-light:#22863A">div</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The relationship between the Fiber tree produced by these components and their output is as follows.</p>
<pre><code>프로필 (출력: 없음, 컴포넌트 함수)
  │
  └─► div.프로필 (출력: &#x3C;div class="프로필">...&#x3C;/div>)
       │
       ├─► 아바타 (출력: 없음, 컴포넌트 함수)
       │    │
       │    └─► img (출력: &#x3C;img src="profile.jpg" alt="프로필">)
       │
       └─► 유저정보 (출력: 없음, 컴포넌트 함수)
            │
            └─► div (출력: &#x3C;div>...&#x3C;/div>)
                 │
                 ├─► h2 (출력: &#x3C;h2>홍길동&#x3C;/h2>)
                 │
                 └─► p (출력: &#x3C;p>개발자&#x3C;/p>)
</code></pre>
<p>output is collected <strong>from the bottom up</strong>. DOM nodes are created first at the leaf (host) nodes.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 호스트 컴포넌트들이 실제 DOM 정보 생성</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">img_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'img'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  src: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'profile.jpg'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  alt: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'프로필'</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">h2_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'h2'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {}, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'홍길동'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">p_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'p'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {}, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'개발자'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p>Next, the parent host component collects the output of its children.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// div 노드가 자식들의 출력을 수집</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">유저정보_div_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'div'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {}, [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  h2_fiber.output,  </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;h2>홍길동&#x3C;/h2></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  p_fiber.output    </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;p>개발자&#x3C;/p></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 최상위 div가 모든 자식 출력을 수집</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">프로필_div_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createDOMElement</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'div'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, {className: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'프로필'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}, [</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  img_fiber.output,           </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;img src="profile.jpg" alt="프로필"></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  유저정보_div_fiber.output   </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// &#x3C;div>&#x3C;h2>홍길동&#x3C;/h2>&#x3C;p>개발자&#x3C;/p>&#x3C;/div></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]);</span></span></code></pre></figure>
<p>Finally, user-defined components pass their child's output through unchanged.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 사용자 정의 컴포넌트는 자식의 출력을 위로 전달</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">아바타_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> img_fiber.output;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">유저정보_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 유저정보_div_fiber.output;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">프로필_fiber.output </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> 프로필_div_fiber.output;</span></span></code></pre></figure>
<h2 id="fiber-scheduling"><a class="anchor" href="#fiber-scheduling">Fiber Scheduling</a></h2>
<p>If Fiber's core value is that it can "divide work," where does that division actually happen? In the <strong>Work Loop</strong>.</p>
<h3 id="work-loop-the-heart-of-fiber-traversal"><a class="anchor" href="#work-loop-the-heart-of-fiber-traversal">Work Loop: The Heart of Fiber Traversal</a></h3>
<p>React rendering begins in the Work Loop defined in <code>ReactFiberWorkLoop.js</code>. React uses one of two Work Loops depending on the situation.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 동기 렌더링: 중단 없이 모든 Fiber를 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopSync</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(workInProgress);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 동시성 렌더링: Scheduler가 양보하라고 할 때까지 처리</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> workLoopConcurrentByScheduler</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">shouldYield</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(workInProgress);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Notice the difference between the two functions. <code>workLoopSync</code> runs <strong>unconditionally</strong> until <code>workInProgress</code> becomes <code>null</code>. By contrast, <code>workLoopConcurrentByScheduler</code> asks <code>shouldYield()</code> after each Fiber it processes and exits the loop when the Scheduler answers that it should yield.</p>
<p>The Scheduler answers by checking whether <strong>5 ms</strong> have passed since the current task started. Those 5 ms are not the size of a work slice but the threshold for the yield check, so if processing a single Fiber takes longer than that, the work is not split. The same file also contains <code>workLoopConcurrent</code>, which makes non-idle work such as Transition yield every 25 ms. But that function sits behind the <code>enableThrottledScheduling</code> flag, and the flag is off in every build of v19.3.0.</p>
<h3 id="performunitofwork"><a class="anchor" href="#performunitofwork">performUnitOfWork</a></h3>
<p><code>performUnitOfWork</code> processes a single Fiber node. The core of Fiber traversal is contained in this function.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> performUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">unitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> current</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork.alternate;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> next</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> beginWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, unitOfWork, renderLanes);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  unitOfWork.memoizedProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork.pendingProps;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (next </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    completeUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(unitOfWork);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p><code>beginWork</code> processes the current node and returns its first child. It then finalizes <code>pendingProps</code> as <code>memoizedProps</code>; if there is a child, processing moves to that child, and otherwise it calls <code>completeUnitOfWork</code>.</p>
<h3 id="beginwork"><a class="anchor" href="#beginwork">beginWork</a></h3>
<p><code>beginWork</code> traverses Fiber nodes from top to bottom and performs the required computation at each node. It is defined in <code>ReactFiberBeginWork.js</code> and internally branches through a huge <strong>switch statement</strong> based on the Fiber's <code>tag</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> beginWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">current</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">workInProgress</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">renderLanes</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // bailout 체크: props와 context가 변경되지 않았다면 스킵</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (current </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> oldProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> current.memoizedProps;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> newProps</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> workInProgress.pendingProps;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (oldProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> newProps </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x26;&#x26;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> !</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">hasContextChanged</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bailoutOnAlreadyFinishedWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, renderLanes);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  switch</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (workInProgress.tag) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> FunctionComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateFunctionComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ClassComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateClassComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> HostComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateHostComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    case</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> SuspenseComponent:</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> updateSuspenseComponent</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, workInProgress, </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // ... 약 25가지 이상의 케이스</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The key is the <strong>bailout check</strong> at the top. If props and context are the same as before, <code>bailoutOnAlreadyFinishedWork</code> skips the entire subtree. This is one of the most important paths in React's performance optimization.</p>
<p>The return value of <code>beginWork</code> is the <strong>first child Fiber</strong>. If a child exists, it becomes the next <code>workInProgress</code>; if not (<code>null</code>), execution enters <code>completeUnitOfWork</code>.</p>
<h3 id="completework"><a class="anchor" href="#completework">completeWork</a></h3>
<p><code>completeWork</code> starts at a leaf node and finishes work while moving upward toward the parent.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> completeUnitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">unitOfWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> unitOfWork;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  do</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 1. completeWork로 현재 노드의 작업 마무리 (DOM 생성 등)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    completeWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(current, completedWork, renderLanes);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 2. 형제가 있으면 형제로 이동 (다시 beginWork 시작)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> siblingFiber</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.sibling;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (siblingFiber </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> siblingFiber;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">    // 3. 형제가 없으면 부모로 올라감</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.return;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    workInProgress </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (completedWork </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The main work performed in <code>completeWork</code> is as follows.</p>
<ul>
<li><strong>For a HostComponent</strong>: It creates the actual DOM node (<code>createInstance</code>) and appends the child DOM nodes. If the DOM already exists, it collects the changed props and stores them in <code>updateQueue</code>.</li>
<li><strong><code>bubbleProperties()</code></strong>: It aggregates the children's flags into <code>subtreeFlags</code>. This information is used to optimize subtree skipping during the Commit Phase.</li>
</ul>
<p>To summarize the traversal: <strong>descend along child (beginWork) -> after completing a leaf, move to sibling -> if there is no sibling, ascend along return (completeWork)</strong>. This is Fiber's depth-first traversal order.</p>
<h3 id="the-schedulers-task-queues"><a class="anchor" href="#the-schedulers-task-queues">The Scheduler's Task Queues</a></h3>
<p>React's Scheduler package internally manages <strong>two min-heaps</strong>.</p>
<pre><code>timerQueue (대기실)                    taskQueue (실행 대기열)
┌──────────────────┐                  ┌──────────────────┐
│ 아직 시작 시간이     │   startTime      │ 지금 실행 가능한     │
│ 안 된 태스크들       │ ──경과 시──→      │ 태스크들           │
│                  │                  │                  │
│ 정렬: startTime   │                  │ 정렬: expiration  │
│ (빠른 순)          │                  │ Time (임박한 순)   │
└──────────────────┘                  └──────────────────┘
</code></pre>
<p><strong>taskQueue</strong> is the queue of tasks that "can run right now." The smaller the <code>expirationTime</code> (= startTime + timeout)—that is, the closer expiration is—the sooner the task runs. <strong>timerQueue</strong> is the waiting room for tasks whose execution time has not yet arrived. The moment the current time passes startTime, a task moves into taskQueue.</p>
<p>So how is the timeout that determines expirationTime chosen? Each update receives a distinct timeout based on its Priority Level.</p>
<pre><code>우선순위          timeout        만료까지         예시
─────────────────────────────────────────────────────────
Immediate        -1ms          즉시 만료         flushSync
UserBlocking     250ms         0.25초           클릭, 입력
Normal           5,000ms       5초              일반 setState
Low              10,000ms      10초             startTransition
Idle             ~1,073,741,823ms  ~12.4일      오프스크린 렌더링
</code></pre>
<p><strong>Immediate</strong> expires as soon as it is created. It receives top priority the instant it enters taskQueue. (Being born already expired is a rather melancholy fate.) <strong>UserBlocking</strong> uses 250 ms to match the threshold at which people perceive a response as slow (100–300 ms). If nothing happens within a quarter of a second after a click, users become frustrated. <strong>Normal</strong>'s five seconds may seem generous, but it is a guarantee that the task will be processed even in the worst case. In practice, it runs as soon as preceding work finishes. <strong>Idle</strong>'s roughly 12.4 days is effectively infinite. It runs only after all other work is complete. (People rarely leave a browser open for 12 days, so treating it as infinite is reasonable.)</p>
<p>These timeout values also serve as a mechanism for preventing <strong>starvation</strong>. No matter how low the priority, once the timeout passes, the task expires and is forced to run. A constant stream of high-priority work therefore cannot cause low-priority work to be ignored forever.</p>
<h2 id="render-phase-and-commit-phase"><a class="anchor" href="#render-phase-and-commit-phase">Render Phase and Commit Phase</a></h2>
<p>We have now examined Fiber's structure and scheduling. Let us put the full flow together and see how all these pieces combine to update the actual UI.</p>
<p>Fiber internally passes through two stages: the <strong>Render Phase</strong> and the <strong>Commit Phase</strong>. This separation is the core design that makes React's concurrency model possible. Click the image below to see Fiber's execution flow for yourself.</p>
<p><a href="https://storied-centaur-55230f.netlify.app/" target="_blank" rel="noopener noreferrer"><picture><source type="image/webp" srcset="/content/250520/2.720w.webp 720w, /content/250520/2.1440w.webp 1440w, /content/250520/2.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/250520/2.png" alt="An interactive demo that walks through React Fiber step by step, from the render phase to the layout phase, alongside the component tree and work stack" width="1680" height="1202" loading="eager" fetchpriority="high" decoding="async"></picture></a></p>
<h3 id="render-phase"><a class="anchor" href="#render-phase">Render Phase</a></h3>
<p>The Render Phase is the stage that <strong>calculates which UI changes are needed</strong>. It has no actual effect on the DOM. Its most important characteristic is that it can be <strong>paused and resumed asynchronously</strong>.</p>
<p>This stage operates primarily through <code>beginWork</code> and <code>completeWork</code>, which we examined earlier.</p>
<p>In <strong>beginWork(fiber)</strong>, React executes the appropriate logic for each Fiber type (FunctionComponent, ClassComponent, HostComponent, and so on). It then creates and connects child Fiber nodes. If props are the same as before, memoization can be used to skip the work (bailout).</p>
<p>In <strong>completeWork(fiber)</strong>, React prepares DOM creation work or effect information. Through <code>bubbleProperties()</code>, it aggregates the children's flags into <code>subtreeFlags</code> and fills in information while moving upward toward the parent.</p>
<p>Because this phase does not modify the DOM directly, work can be stopped at any time and resumed later without exposing an incomplete UI to the user. This is the foundation of Concurrent Mode.</p>
<h3 id="subtreeflags"><a class="anchor" href="#subtreeflags">subtreeFlags</a></h3>
<p>During the Render Phase, each Fiber records required side effects as <strong>bit flags</strong>. Let us examine the major flags defined in <code>ReactFiberFlags.js</code>.</p>
<ul>
<li><code>Placement</code>: Insert a new node into the DOM</li>
<li><code>Update</code>: A DOM property update is required</li>
<li><code>ChildDeletion</code>: A child node must be deleted</li>
<li><code>Ref</code>: A ref must be attached or detached</li>
<li><code>Passive</code>: A useEffect callback must run</li>
<li><code>Snapshot</code>: Run getSnapshotBeforeUpdate</li>
<li><code>Callback</code>: Run a lifecycle callback</li>
</ul>
<p>Earlier versions of React (through approximately version 16) collected only Fibers with side effects using a linked list connected through <code>firstEffect</code> -> <code>nextEffect</code> -> <code>lastEffect</code>. However, this approach could retain references to unmounted Fibers, causing <strong>memory leaks</strong>, and made it difficult to process new patterns such as Suspense efficiently.</p>
<p>Starting with React 17, this effect list was removed in favor of the <strong>subtreeFlags approach</strong> (<a href="https://github.com/facebook/react/pull/19381" target="_blank" rel="noopener noreferrer">PR #19381</a>). During <code>completeWork</code>, <code>bubbleProperties()</code> aggregates child flags into the parent.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> bubbleProperties</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">completedWork</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> NoFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> completedWork.child;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child.subtreeFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child.flags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    child </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> child.sibling;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  completedWork.subtreeFlags </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">|=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> subtreeFlags;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The greatest advantage of this structure is that the Commit Phase can <strong>skip an entire subtree</strong>. If a Fiber satisfies <code>subtreeFlags &#x26; MutationMask === NoFlags</code>, there are no nodes anywhere in that subtree requiring a DOM change, so the whole subtree can be skipped. This optimization was impossible with the former linked-list approach.</p>
<h3 id="commit-phase"><a class="anchor" href="#commit-phase">Commit Phase</a></h3>
<p>The Commit Phase is the stage that <strong>applies the changes calculated during the Render Phase to the actual DOM</strong>. This phase is <strong>always synchronous</strong>; once it begins, it runs to completion without interruption. This prevents users from seeing a partially updated UI.</p>
<p>Internally, the Commit Phase proceeds in the following detailed order.</p>
<ol>
<li>
<p><strong>Before Mutation Phase</strong>: <code>commitBeforeMutationEffects()</code></p>
<ul>
<li>Reads the current DOM state before changing the DOM. The <code>getSnapshotBeforeUpdate</code> lifecycle runs here. At this point, the <code>current</code> tree still represents the state on screen, so information such as DOM scroll position and dimensions can be captured safely.</li>
</ul>
</li>
<li>
<p><strong>Mutation Phase</strong>: <code>commitMutationEffects()</code></p>
<ul>
<li>This is where <strong>actual DOM manipulation</strong> happens. New nodes are inserted, existing nodes are modified, and unnecessary nodes are removed. <code>componentWillUnmount</code> also runs here because <code>current</code> still points to the previous tree, allowing the previous state to be read.</li>
</ul>
</li>
<li>
<p><strong>Tree swap</strong>: <code>root.current = finishedWork</code></p>
<ul>
<li>This is the essence of double buffering. The workInProgress tree is promoted to the current tree. The reason the swap happens after Mutation but before Layout is important. <code>componentWillUnmount</code> must read the <strong>previous tree</strong>, so it must run during Mutation, while <code>componentDidMount</code>/<code>componentDidUpdate</code> must read the <strong>new tree</strong>, so they must run during Layout.</li>
</ul>
</li>
<li>
<p><strong>Layout Phase</strong>: <code>commitLayoutEffects()</code></p>
<p>After DOM changes are complete, tasks based on the new DOM state run.</p>
<ul>
<li>Run <code>componentDidMount</code> and <code>componentDidUpdate</code></li>
<li>Run <code>useLayoutEffect</code> callbacks</li>
<li>At this point, <code>current</code> already points to the new tree, so reading the DOM returns updated values</li>
</ul>
</li>
<li>
<p><strong>Passive Effects</strong> (asynchronous)</p>
<ul>
<li><code>useEffect</code> cleanup and setup are scheduled separately and run <strong>asynchronously</strong>. Because they handle side effects that do not depend on DOM changes (such as data fetching and event subscriptions), they do not need to run synchronously. Running them asynchronously yields so the browser can paint the screen first.</li>
</ul>
</li>
</ol>
<h2 id="concurrent-features-and-fiber"><a class="anchor" href="#concurrent-features-and-fiber">Concurrent Features and Fiber</a></h2>
<p>Now let us use the Concurrent Features introduced in React 18 to see what kind of user experience Fiber's entire design—double buffering, Lane-based priority, and an interruptible Work Loop—actually enables.</p>
<h3 id="usetransition"><a class="anchor" href="#usetransition">useTransition</a></h3>
<p>When <code>startTransition(() => setState(...))</code> is called, the update receives a <code>TransitionLane</code>. Fourteen TransitionLanes are assigned in round-robin fashion (one after another in sequence) to prevent collisions.</p>
<p>Because TransitionLane has lower priority than SyncLane or DefaultLane, incoming urgent updates such as user input can <strong>interrupt</strong> transition rendering and be processed first. During that time, the <code>current</code> tree (the previous state) remains on screen while the transition proceeds in the workInProgress tree in the background.</p>
<p>This is where the value of double buffering shines. Interrupted transition rendering affects only the workInProgress tree, leaving the screen the user sees (the current tree) entirely intact.</p>
<p>The <code>isPending</code> flag indicates that the transition is not yet complete, making it possible to display a loading indicator or perform similar handling.</p>
<h3 id="usedeferredvalue"><a class="anchor" href="#usedeferredvalue">useDeferredValue</a></h3>
<p>On the initial render, <code>useDeferredValue(value)</code> returns the supplied <code>value</code> unchanged. On subsequent renders, if the current render is urgent, it returns the previous memoized value and schedules a new render with a TransitionLane. Like a Transition, the deferred render can be interrupted.</p>
<p>Conceptually, it is similar to <code>startTransition</code>, but the difference is that it is applied on the <strong>receiving side of a value</strong>, rather than where an update is dispatched. A typical use case is updating the text in a search field immediately while deferring the rendering of the search results list.</p>
<h3 id="suspense"><a class="anchor" href="#suspense">Suspense</a></h3>
<p>When a component throws a Promise inside <code>&#x3C;Suspense></code>, <code>throwException</code> catches it and marks that Fiber as <code>Incomplete</code>. It then follows the <code>return</code> chain upward to find the nearest Suspense boundary and switches that boundary to display its fallback UI. When the Promise resolves, <code>markRootPinged</code> pings the relevant lane, and React renders the suspended subtree again.</p>
<p>In Concurrent Mode, React can continue rendering the suspended component's <strong>sibling nodes</strong>, so a single data request does not block rendering of the entire tree. This is possible because Fiber's linked-list structure allows free movement to a sibling.</p>
<h3 id="streaming-ssr-and-selective-hydration"><a class="anchor" href="#streaming-ssr-and-selective-hydration">Streaming SSR and Selective Hydration</a></h3>
<p>React 18's <code>renderToPipeableStream</code> uses Suspense boundaries.</p>
<ul>
<li><strong>Server</strong>: When a Suspense boundary suspends, the server sends fallback HTML first and later streams the actual content in a <code>&#x3C;script></code> tag once the data is ready</li>
<li><strong>Client (Selective Hydration)</strong>: Each Suspense boundary can be hydrated <strong>independently</strong>. If the user clicks an area that has not yet been hydrated, React uses <code>SelectiveHydrationLane</code> to hydrate that boundary <strong>first</strong> and then dispatches the event</li>
</ul>
<p>All of this is possible because each Suspense boundary is a Fiber node that can be scheduled independently. Ultimately, the central design of the Fiber architecture—"divide work, assign priorities, and pause/resume"—forms the foundation of these features.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>To summarize this article in one sentence, <strong>React Fiber is an architecture that replaces recursion with iteration and moves the call stack into the heap, making rendering interruptible and resumable</strong>.</p>
<p>It combines many sophisticated designs to accomplish this, including a linked-list-based tree structure, double buffering, a Lane-based priority system, and a MessageChannel-based scheduler. All of these ultimately serve one goal: <strong>maximizing the responsiveness of the UI as experienced by the user</strong>.</p>
<p>Of course, Fiber's internal implementation continues to change with each React release, and what this article covers is only a snapshot from a particular point in time. Still, I believe Fiber's core philosophy—"divide work, assign priorities, pause, and resume"—will remain unchanged.</p>
<p>I hope this article has conveyed that React Fiber is not merely an interview keyword but the runtime architecture supporting every React feature. There may be no single correct interpretation, but I also hope readers will inspect the source code themselves and build their own understanding.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberWorkLoop.js" target="_blank" rel="noopener noreferrer">React source code, ReactFiberWorkLoop.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberBeginWork.js" target="_blank" rel="noopener noreferrer">React source code, ReactFiberBeginWork.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberCompleteWork.js" target="_blank" rel="noopener noreferrer">React source code, ReactFiberCompleteWork.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiberLane.js" target="_blank" rel="noopener noreferrer">React source code, ReactFiberLane.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/react-reconciler/src/ReactFiber.js" target="_blank" rel="noopener noreferrer">React source code, ReactFiber.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/main/packages/scheduler/src/forks/Scheduler.js" target="_blank" rel="noopener noreferrer">React source code, Scheduler.js</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/issues/7942" target="_blank" rel="noopener noreferrer">Issue #7942, Fiber Principles</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://github.com/reactwg/react-18/discussions/37" target="_blank" rel="noopener noreferrer">React 18 WG, New Suspense SSR Architecture</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://github.com/reactwg/react-18/discussions/27" target="_blank" rel="noopener noreferrer">React 18 WG, Concurrent Scheduling</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/blog/2022/03/29/react-v18" target="_blank" rel="noopener noreferrer">React v18.0 Blog Post</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[Why React Uses MessageChannel]]></title>
            <link>https://hooninedev.com/en/250515</link>
            <guid isPermaLink="false">https://hooninedev.com/en/250515</guid>
            <pubDate>Thu, 15 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about why React schedules its work with MessageChannel instead of requestIdleCallback. This is for frontend developers who studied Fiber, read that React "does a little wo...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>why React schedules its work with MessageChannel instead of requestIdleCallback</strong>.</p>
<p>This is for frontend developers who studied Fiber, read that React "does a little work whenever the browser is idle," and were then confused to find <code>MessageChannel</code> in React's actual source code. To give the answer first: <code>requestIdleCallback</code> was not called as often as React needed, and <code>setTimeout</code> adds a delay of more than 4 ms once calls are nested. So React's Scheduler package uses <code>MessageChannel</code>, which in the browser can schedule the next macrotask without any artificial delay. How large that 4 ms really is, I check with numbers measured in headless Chrome.</p>
<h2 id="scheduling-approaches-react-dropped"><a class="anchor" href="#scheduling-approaches-react-dropped">Scheduling approaches React dropped</a></h2>
<p>Explanations of Fiber's concept often use code that splits work up with <code>requestIdleCallback</code>. It is a model in which one unit of work is processed each time the browser has nothing else to do. React did actually use this API at first, and it went through several PRs before reaching its current shape.</p>
<ul>
<li><strong>January 2017</strong>: React used the native <code>requestIdleCallback</code>, and in browsers without it, a polyfill that imitated it with <code>requestAnimationFrame</code> and <code>postMessage</code> (<a href="https://github.com/facebook/react/pull/8833" target="_blank" rel="noopener noreferrer">PR #8833</a>). Browsers lacking the API, such as Safari, were handled by the polyfill from the start. Stable Safari <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback#browser_compatibility" target="_blank" rel="noopener noreferrer">still does not have this API as of October 2026</a>.</li>
<li><strong>March and April 2018</strong>: A flag was added to use the polyfill even when the native API existed. Andrew Clark wrote in the PR description that the point was to test whether the polyfill reduced the starvation issues they had been seeing (work never getting a chance to run and being pushed back again and again), adding that it was hard to say for certain because those issues were difficult to reproduce (<a href="https://github.com/facebook/react/pull/12385" target="_blank" rel="noopener noreferrer">PR #12385</a>). A month later, having determined internally that it worked better than the native implementation, the team removed the flag and made the polyfill permanent (<a href="https://github.com/facebook/react/pull/12648" target="_blank" rel="noopener noreferrer">PR #12648</a>).</li>
<li><strong>November 2018</strong>: The message event that the polyfill had been posting to <code>window</code> was moved to a <code>MessageChannel</code>. Posting to <code>window</code> makes every other message handler on the page run on every frame as well (<a href="https://github.com/facebook/react/pull/14234" target="_blank" rel="noopener noreferrer">PR #14234</a>).</li>
<li><strong>July 2019</strong>: Instead of guessing the next vsync and yielding at the end of the frame, an experimental flag introduced a loop that works for 5 ms inside a message event and then yields (<a href="https://github.com/facebook/react/pull/16214" target="_blank" rel="noopener noreferrer">PR #16214</a>). This loop does not use <code>requestAnimationFrame</code> at all.</li>
<li><strong>August and November 2019</strong>: In August came a report that performance tests showed better CPU utilization for the message loop (<a href="https://github.com/facebook/react/pull/16271" target="_blank" rel="noopener noreferrer">PR #16271</a>, not merged), and in November the rAF implementation was deleted (<a href="https://github.com/facebook/react/pull/17252" target="_blank" rel="noopener noreferrer">PR #17252</a>).</li>
</ul>
<p>Why the starvation happens can be inferred from how <code>requestIdleCallback</code> is defined. The <a href="https://w3c.github.io/requestidlecallback/" target="_blank" rel="noopener noreferrer">W3C specification</a> leaves the idle period to the browser to decide, and gives the time left over between one frame and the next as one of its examples. Such periods come often during animation, but on a 60 Hz display they are typically shorter than 16 ms. When the main thread is busy with long tasks, even these shrink, and React's work, in my view, gets pushed back accordingly. Dan Abramov also wrote in an August 2018 issue comment that React stopped using this API because "it's not as aggressive as we need" (<a href="https://github.com/facebook/react/issues/11171#issuecomment-417349573" target="_blank" rel="noopener noreferrer">facebook/react#11171</a>).</p>
<p>The reasons for dropping the later <code>requestAnimationFrame</code> approach are in the description of PR #16214. That approach had to guess when the next vsync (the signal tied to the display's refresh cycle) would come. It started by assuming 30 fps, with a frame length of 33.33 ms, and whenever two consecutive frame intervals were both shorter than that, it shrank the frame length to the longer of the two. The deadline for the current frame was the frame's start time plus that length (<a href="https://github.com/facebook/react/blob/6dc2734b41aef944e457eaa23ae218952fce0a54/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L123-L336" target="_blank" rel="noopener noreferrer">SchedulerHostConfig.default.js just before PR #17252</a>). Because its only rule shrank the frame length, it could detect the refresh rate going up after page load but not going down, as the PR description says.</p>
<p>The message loop yields every 5 ms wherever it is in the vsync cycle. The PR description expected this to keep the main thread responsive even on displays with very high refresh rates ("should keep the main thread responsive"). The same PR also noted the risk that yielding more often could increase contention with other browser tasks, and that it was unclear how much message events are throttled in a background tab. The current Scheduler source comment says that most tasks do not need to be aligned with frame boundaries.</p>
<h2 id="messagechannel"><a class="anchor" href="#messagechannel">MessageChannel</a></h2>
<p>What remained as the scheduling mechanism once rAF was removed is <strong>MessageChannel</strong>. Below are <a href="https://github.com/facebook/react/blob/v19.3.0/packages/scheduler/src/forks/Scheduler.js#L530-L561" target="_blank" rel="noopener noreferrer">lines 530 to 561 of Scheduler.js</a> in React v19.3.0, with the comments trimmed.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> schedulePerformWorkUntilDeadline;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> localSetImmediate </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'function'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // Node.js and old IE.</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    localSetImmediate</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWorkUntilDeadline);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> MessageChannel </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'undefined'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // DOM and Worker environments.</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // We prefer MessageChannel because of the 4ms setTimeout clamping.</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> channel</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MessageChannel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> port</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> channel.port2;</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  channel.port1.onmessage </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performWorkUntilDeadline;</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    port.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">postMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">} </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">  // We should only fallback here in non-browser environments.</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  schedulePerformWorkUntilDeadline</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    localSetTimeout</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performWorkUntilDeadline, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>There are three branches. Today's browsers do not have <code>setImmediate</code>, so the second branch, <code>MessageChannel</code>, is taken. According to the source comment, the first branch exists for Node.js and old IE, and <code>MessageChannel</code> keeps a Node.js process from exiting while <code>setImmediate</code> does not (<a href="https://github.com/facebook/react/issues/20756" target="_blank" rel="noopener noreferrer">facebook/react#20756</a>). So Jest's node environment takes the <code>setImmediate</code> path. Since Jest 27, the jsdom environment has had <code>setImmediate</code> removed from its globals (<a href="https://github.com/jestjs/jest/pull/11222" target="_blank" rel="noopener noreferrer">jestjs/jest#11222</a>) and has no <code>MessageChannel</code> either, so it takes the <code>setTimeout</code> path.</p>
<p>The purpose of scheduling the next turn like this is to hand the main thread back to the browser. While JavaScript holds the main thread, the browser can neither handle input nor paint the screen. Only time-sliced renders such as Transition and Retry yield partway through, though. The Reconciler in React v19.3.0 renders to the end without yielding when the render includes a blocking lane such as Sync, InputContinuous, or Default, includes a lane that waited so long it expired, or was called with <code>forceSync</code> (<a href="https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactFiberWorkLoop.js#L1168" target="_blank" rel="noopener noreferrer">line 1168 of ReactFiberWorkLoop.js</a>).</p>
<p>In those renders, the Reconciler asks <code>shouldYield()</code> after processing each Fiber (<a href="https://github.com/facebook/react/blob/v19.3.0/packages/react-reconciler/src/ReactFiberWorkLoop.js#L3073-L3078" target="_blank" rel="noopener noreferrer">lines 3073 to 3078 of ReactFiberWorkLoop.js</a>). That function is exported by the Scheduler, and the actual decision is made by <code>shouldYieldToHost()</code> inside the Scheduler. The criterion is the time elapsed since the current message task started. Once that time reaches <code>frameInterval</code>, it yields, and if <code>requestPaint()</code> was called after a commit, it yields regardless of time. The initial value of <code>frameInterval</code> is the <code>SchedulerFeatureFlags.js</code> constant <code>frameYieldMs</code>, which is <strong>5 ms</strong> (<a href="https://github.com/facebook/react/blob/v19.3.0/packages/scheduler/src/SchedulerFeatureFlags.js#L11" target="_blank" rel="noopener noreferrer">line 11</a>). 5 ms is not the size of a work slice but the time threshold for checking whether to yield. If rendering a single component takes 20 ms, those 20 ms are not split. All of this describes the stable build. In experimental builds, <code>enableAlwaysYieldScheduler</code> in the same file is turned on, so the Scheduler yields to the browser as soon as it finishes one task, unless the next task has already expired, instead of filling 5 ms, and it ignores the <code>requestPaint()</code> signal.</p>
<h2 id="the-4-ms-delay-of-settimeout"><a class="anchor" href="#the-4-ms-delay-of-settimeout">The 4 ms Delay of setTimeout</a></h2>
<p>Why is it not <code>setTimeout</code> but <code>MessageChannel</code> that schedules the next slice after yielding? The <a href="https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps" target="_blank" rel="noopener noreferrer">timer initialization steps</a> of the HTML specification raise the delay of a <code>setTimeout</code> whose nesting level is greater than 5 to 4 ms if it is shorter than that. Measuring a chain where <code>setTimeout(fn, 0)</code> re-arms itself in headless Chrome, the gaps for the 1st through 6th calls were 0 to 0.1 ms, and from the 7th call on, 4 to 5 ms (usually around 5 ms) were added. Messages on a <code>MessageChannel</code> have no such minimum delay. A message is simply queued as a task, so the browser can slip in input handling or rendering in between, and that gap is exactly what the Scheduler is after.</p>
<p>4 ms looks small, but it is a different story in a loop that yields every 5 ms. I split 200 ms of work into forty 5 ms slices and scheduled the next slice with <code>MessageChannel</code> and with <code>setTimeout(work, 0)</code>. Paste it into the DevTools console and it runs as is.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> busy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">ms</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> s</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(); </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ms) {} }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> ch</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> MessageChannel</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, frames </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> raf</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { frames</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(raf); };</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> start</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> work</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  busy</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 5</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (done </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 200</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">schedule</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  else</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">round</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(performance.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">now</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> start), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'ms,'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, frames, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'frames'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">ch.port1.onmessage </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> work;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> schedule</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ch.port2.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">postMessage</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">); </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// setTimeout(work, 0) 로 바꿔 비교</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">requestAnimationFrame</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(raf);</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">schedule</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">();</span></span></code></pre></figure>
<p>These are the results of running three variants eight times each on 2026-10-08 in Chrome 154.0.8037.98 (headless) on macOS. Safari and Firefox were not measured. The third row of the table replaces <code>busy(5); done += 5;</code> with <code>busy(200); done += 200;</code> so the work is not split.</p>
<table>
<thead>
<tr>
<th>How the next slice is scheduled</th>
<th>Time to finish</th>
<th>Frames drawn meanwhile</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MessageChannel</code></td>
<td>201 ms</td>
<td>12 to 13</td>
</tr>
<tr>
<td><code>setTimeout(work, 0)</code></td>
<td>about 365 to 371 ms</td>
<td>21 to 22</td>
</tr>
<tr>
<td>Not split</td>
<td>200 ms</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>With <code>setTimeout</code>, after the first six calls each slice gained 4 to 5 ms of idle time, so the same work took about 1.8 times as long. The numbers shift with the machine and its load, but the ratio of about 1.8 held across repeated runs. The higher frame count is because the work finished late and the measured window grew longer. It does not mean better responsiveness. Without splitting, the work finishes in 200 ms, but not a single frame is drawn during that time. <code>MessageChannel</code> finished the work within the same 200 ms while usually continuing to produce frames at about 60 fps.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>To summarize, what React needed was not an API that waits until the browser is idle, but one that lets it work briefly and immediately schedule its next turn. <code>requestIdleCallback</code> was not called as aggressively as React needed; the <code>requestAnimationFrame</code> approach had to guess when vsync would come; and <code>setTimeout</code> made each slice rest for more than 4 ms once nested. <code>MessageChannel</code> is what met those conditions.</p>
<p>How the units of work this Scheduler runs, the Fiber nodes, are structured and how the Work Loop traverses them is covered in <a href="/250520" target="_blank" rel="noopener noreferrer">Mastering React Fiber</a>. The next time you come across <code>MessageChannel</code> in React's source code, I hope you will take a moment to recall why it is there.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#10b981"></span><a href="https://github.com/facebook/react/blob/v16.0.0/src/renderers/shared/ReactDOMFrameScheduling.js" target="_blank" rel="noopener noreferrer">ReactDOMFrameScheduling.js in React 16.0.0</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[Can Biome Replace ESLint and Prettier?]]></title>
            <link>https://hooninedev.com/en/241201</link>
            <guid isPermaLink="false">https://hooninedev.com/en/241201</guid>
            <pubDate>Sun, 01 Dec 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about a tool called Biome. The team I work on had considerable difficulty maintaining a consistent code style in an environment where people used different IDEs, including...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about a tool called Biome.</p>
<p>The team I work on had considerable difficulty maintaining a consistent code style in an environment where people used different IDEs, including WebStorm and VSCode. Managing separate configuration files for each IDE was cumbersome, and code reviews often filled up with comments about formatting differences unrelated to the actual logic.</p>
<p>When ESLint's formatting-related rules were deprecated, we needed to find a new alternative. The <strong>Prettier + ESLint</strong> combination required additional configuration to prevent conflicts between the tools, while <strong>@stylistic/eslint-plugin-ts</strong> was still in the early stages of community adoption and had not yet been proven stable. That was when Biome caught our attention.</p>
<p>So what exactly is Biome, and can it really replace ESLint and Prettier?</p>
<h2 id="what-is-biome"><a class="anchor" href="#what-is-biome">What Is Biome?</a></h2>
<p>Biome is an all-in-one toolchain for web projects. It provides integrated code formatting and linting for JavaScript, TypeScript, JSX, CSS, JSON, GraphQL, and more in a single tool. Its core philosophy is to handle the roles traditionally split between ESLint and Prettier with one binary.</p>
<p>Biome's predecessor was <a href="https://github.com/rome/tools" target="_blank" rel="noopener noreferrer">Rome</a>. <strong>Rome Tools Inc.</strong> launched ambitiously after raising $4.5M in venture funding in 2021, but by mid-2023 its entire staff had been laid off and the repository was archived. Core contributors then forked the project and gave it a fresh start as Biome in August 2023. Moving beyond Rome's image of "overpromising and underdelivering," it has been steadily building trust through practical, consistent releases.</p>
<p>Its most notable characteristic is that it is written in Rust. We will look more closely at the performance difference this creates later.</p>
<h2 id="why-use-biome"><a class="anchor" href="#why-use-biome">Why Use Biome?</a></h2>
<p>There are three main reasons to choose Biome.</p>
<p><strong>One tool handles both formatting and linting.</strong> With the ESLint + Prettier combination, additional configuration such as <code>eslint-config-prettier</code> was required to prevent rule conflicts between the two tools. Biome eliminates that complexity at its source.</p>
<p><strong>Its performance is exceptional.</strong> According to official benchmarks, it is roughly 35 times faster than Prettier and about 15 times faster than ESLint. We will compare what those figures look like in practice later.</p>
<p><picture><source type="image/webp" srcset="/content/241201/1.720w.webp 720w, /content/241201/1.1440w.webp 1440w, /content/241201/1.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/1.png" alt="Biome&#x27;s own formatting benchmark: 0.41s versus Prettier&#x27;s 14.35s on 171,127 lines across 2,104 files, about 35 times faster" width="1680" height="736" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><strong>It is compatible with existing tools.</strong> Biome offers about 97% formatting compatibility with Prettier and includes major ESLint rules out of the box. Rules from commonly used plugins such as <code>eslint-plugin-react-hooks</code> and <code>eslint-plugin-jsx-a11y</code> are built in as well, making migration relatively manageable.</p>
<h2 id="how-do-you-use-it"><a class="anchor" href="#how-do-you-use-it">How Do You Use It?</a></h2>
<p>Configuring Biome is quite straightforward. The <a href="https://biomejs.dev/guides/getting-started/" target="_blank" rel="noopener noreferrer">official documentation</a> explains it clearly, so take a look there as well.</p>
<p>First, install Biome.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npm</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> install</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --save-dev</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --save-exact</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @biomejs/biome</span></span></code></pre></figure>
<p>Then generate a configuration file.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="bash" data-theme="github-dark github-light"><code data-language="bash" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">npx</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> @biomejs/biome</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> init</span></span></code></pre></figure>
<p>This creates a <code>biome.json</code> file. Define your team's formatting and linting rules in that file.</p>
<p>You also need to install an IDE extension. If you use VSCode, install <a href="https://marketplace.visualstudio.com/items?itemName=biomejs.biome" target="_blank" rel="noopener noreferrer">VSCode Biome</a>; if you use WebStorm, install the <a href="https://plugins.jetbrains.com/plugin/22761-biome" target="_blank" rel="noopener noreferrer">WebStorm Biome</a> plugin.</p>
<p>Finally, add the following configuration to VSCode's <code>settings.json</code> to apply formatting and linting automatically whenever you save.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="json" data-theme="github-dark github-light"><code data-language="json" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "editor.defaultFormatter"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"biomejs.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">  "editor.codeActionsOnSave"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: {</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "quickfix.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"explicit"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">    "source.organizeImports.biome"</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">"explicit"</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<h2 id="lets-compare-them-directly"><a class="anchor" href="#lets-compare-them-directly">Let's Compare Them Directly</a></h2>
<p>Simply saying it is fast does not make the difference tangible, so I compared Biome and ESLint + Prettier on the same project. Biome is on the left, and ESLint + Prettier is on the right.</p>
<h3 id="local-startup-time-for-a-vite-project"><a class="anchor" href="#local-startup-time-for-a-vite-project">Local Startup Time for a Vite Project</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome1.720w.webp 720w, /content/241201/biome1.798w.webp 798w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome1.png" alt="Vite dev server started with Biome, ready in 506ms" width="798" height="330" loading="lazy" fetchpriority="low" decoding="async"></picture>  <picture><source type="image/webp" srcset="/content/241201/lint1.720w.webp 720w, /content/241201/lint1.766w.webp 766w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint1.png" alt="Vite dev server started with ESLint and Prettier, ready in 630ms" width="766" height="248" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome took <strong>506ms</strong>, while ESLint + Prettier took <strong>630ms</strong>, making Biome about 20% faster.</p>
<h3 id="build-time-for-a-vite-project"><a class="anchor" href="#build-time-for-a-vite-project">Build Time for a Vite Project</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome2.720w.webp 720w, /content/241201/biome2.920w.webp 920w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome2.png" alt="Vite build with Biome, finishing in 117.13 seconds" width="920" height="222" loading="lazy" fetchpriority="low" decoding="async"></picture> <picture><source type="image/webp" srcset="/content/241201/lint2.720w.webp 720w, /content/241201/lint2.1022w.webp 1022w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint2.png" alt="Vite build with ESLint and Prettier, finishing in 131.48 seconds" width="1022" height="228" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome took <strong>117.13s</strong>, while ESLint + Prettier took <strong>131.48s</strong>, making Biome about 10% faster.</p>
<h3 id="linting"><a class="anchor" href="#linting">Linting</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/biome3.720w.webp 720w, /content/241201/biome3.894w.webp 894w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/biome3.png" alt="Biome lint run finishing in 0.79 seconds with 0.470 seconds of CPU time" width="894" height="174" loading="lazy" fetchpriority="low" decoding="async"></picture> <picture><source type="image/webp" srcset="/content/241201/lint3.720w.webp 720w, /content/241201/lint3.974w.webp 974w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/lint3.png" alt="ESLint run finishing in 16.32 seconds with 8.600 seconds of CPU time" width="974" height="186" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>The greatest difference appeared during linting. Biome took <strong>0.79s</strong> (CPU 0.470s), while ESLint took <strong>16.32s</strong> (CPU 8.600s), meaning <strong>Biome delivered roughly 20 times faster performance</strong>. Its CPU usage was also much more efficient.</p>
<p>The difference is already noticeable in a development environment, but it becomes even more dramatic when a CI/CD pipeline checks hundreds of files. Because Biome can run its binary directly without an npm installation, it can also reduce CI cold-start time.</p>
<p><picture><source type="image/webp" srcset="/content/241201/3.265w.webp 265w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/3.jpeg" alt="Patrick the cartoon starfish resting his chin on his hand, deep in thought" width="265" height="190" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Hmm... (At this point, it is harder to find a reason not to use it.)</p>
<h2 id="why-is-it-so-fast"><a class="anchor" href="#why-is-it-so-fast">Why Is It So Fast?</a></h2>
<p>"It is fast because it was built with Rust" is true, but that alone is not a complete explanation. Let's examine the specific technical factors behind Biome's performance advantage.</p>
<h3 id="rusts-low-level-performance"><a class="anchor" href="#rusts-low-level-performance">Rust's Low-Level Performance</a></h3>
<table>
<thead>
<tr>
<th><img src="/content/241201/5.webp" alt="Average CPU usage at 10, 100, and 300 concurrent connections: Node.js stays near 89-91% while Rust spans 145-206% across cores" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
<th><img src="/content/241201/6.webp" alt="Average memory use under the same load: Node.js takes 64-89MB while Rust takes 6-14MB" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
</tr>
</thead>
</table>
<p>Biome is written in Rust, a systems programming language. Rust is designed around zero-cost abstractions, meaning high-level abstractions can deliver the same performance as manually optimized low-level code. It also manages memory through an ownership system without a garbage collector (GC), avoiding the runtime overhead caused by GC.</p>
<p>ESLint and Prettier, by contrast, are written in JavaScript and run on the Node.js runtime. Although the V8 engine's JIT (Just-In-Time) compilation optimizes JavaScript, it cannot completely avoid the fundamental limitations of an interpreted language or the cost of garbage collection.</p>
<h3 id="single-parse-architecture"><a class="anchor" href="#single-parse-architecture">Single-Parse Architecture</a></h3>
<p>Biome parses code only once with a single parser to create an AST (Abstract Syntax Tree). It reuses that AST for both formatting and linting.</p>
<p>What happens when you use ESLint + Prettier? ESLint parses the code, creates an AST, and performs linting; then Prettier parses the same code again, creates a separate AST, and performs formatting. The same file is parsed twice. Biome's single-parse architecture eliminates this duplication entirely.</p>
<h3 id="native-parallel-processing"><a class="anchor" href="#native-parallel-processing">Native Parallel Processing</a></h3>
<p><picture><source type="image/webp" srcset="/content/241201/7.720w.webp 720w, /content/241201/7.800w.webp 800w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/7.png" alt="Synchronous versus asynchronous processing: synchronous alternates request and response, asynchronous sends requests back to back and collects responses later" width="800" height="514" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Using Rust's concurrency model, Biome processes files in parallel across multiple threads. It divides work into small units and efficiently distributes the load across threads with a work-stealing scheduler. Because Rust's ownership system prevents data races at compile time, runtime synchronization costs are minimized as well.</p>
<p>Node.js uses a single-threaded, event-loop-based model by default. Worker Threads make parallel processing possible, but they add overhead from thread creation and message passing. Biome directly uses native OS-level threads, allowing it to make full use of CPU cores without that overhead.</p>
<h3 id="memory-efficient-ast-processing"><a class="anchor" href="#memory-efficient-ast-processing">Memory-Efficient AST Processing</a></h3>
<p><img src="/content/241201/4.svg" alt="Syntax tree example: a unary -1 on the left and a binary 1 + 2 on the right, each drawn as operator and integer nodes" width="446" height="360" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Biome uses a CST (Concrete Syntax Tree). According to Biome's official architecture documentation, this CST implements the Green/Red Tree pattern based on an internal fork of the rowan library, preserving all information from the original code, including comments and whitespace. Rowan's arena-style memory allocation places nodes in contiguous memory regions, improving CPU cache locality and minimizing unnecessary object allocation.</p>
<p>With JavaScript's object-based approach to AST processing, each node exists as an independent heap object, scattering memory and increasing GC pressure. Biome's approach enables faster tree traversal while using less memory.</p>
<h2 id="so-should-you-adopt-biome"><a class="anchor" href="#so-should-you-adopt-biome">So, Should You Adopt Biome?</a></h2>
<p>Biome's performance and convenience are clearly appealing. However, I do not think adopting it unconditionally is the right answer for every project. Let's consider a few practical factors.</p>
<h3 id="when-biome-is-a-good-fit"><a class="anchor" href="#when-biome-is-a-good-fit">When Biome Is a Good Fit</a></h3>
<ul>
<li>When you maintain a <strong>large codebase</strong> where build and lint performance matter</li>
<li>When you want to reduce code-checking time in a CI/CD pipeline</li>
<li>When you are tired of the complexity of configuring ESLint + Prettier</li>
<li>When you are starting a new project and want a concise tool configuration</li>
</ul>
<p>My team was also maintaining a large-scale project where linting consumed a great deal of time in the CI pipeline, and developers were frustrated by slow linting speeds, so we decided to adopt Biome.</p>
<h3 id="points-to-watch"><a class="anchor" href="#points-to-watch">Points to Watch</a></h3>
<p><strong>The limited plugin ecosystem is the biggest concern.</strong> ESLint has thousands of community plugins, while Biome is centered on built-in rules. Many rules from major plugins—including <code>eslint-plugin-react</code>, <code>eslint-plugin-react-hooks</code>, <code>eslint-plugin-jsx-a11y</code>, <code>eslint-plugin-unicorn</code>, and <code>typescript-eslint</code>—are built in, but not every rule from each plugin has been ported. A GritQL-based plugin system has been announced for Biome v2, but it is still experimental. Projects that depend on framework-specific rules such as <code>@next/eslint-plugin-next</code> or <code>eslint-plugin-angular</code> need to approach migration carefully.</p>
<p><strong>You should also verify the scope of language support.</strong> JavaScript, TypeScript, JSX, CSS, JSON, and GraphQL are supported reliably, but Vue and Svelte SFC (Single File Component) files have only partial support for their <code>&#x3C;script></code> blocks. HTML, YAML, and Markdown are not yet supported.</p>
<p><strong>Do not forget that ESLint is evolving too.</strong> Flat Config (<code>eslint.config.js</code>), introduced in ESLint v9 in April 2024, significantly simplified the complexity of the previous <code>.eslintrc</code> approach. ESLint has also expanded linting beyond JavaScript with the releases of <code>@eslint/json</code> in October 2024 and <code>@eslint/css</code> in February 2025. The ESLint Stylistic (<code>@stylistic/eslint-plugin</code>) project provides an option to handle formatting with ESLint alone, without Prettier. In other words, Biome's all-in-one advantage is being somewhat diluted by the evolution of the ESLint ecosystem.</p>
<p>The history of the transition from Rome to Biome is also worth remembering. The disruption experienced by existing users when Rome was archived demonstrates how important a project's sustainability is when choosing a tool. Fortunately, Biome is funded through OpenCollective and GitHub Sponsors and continues to maintain a steady release cadence.</p>
<p><picture><source type="image/webp" srcset="/content/241201/8.720w.webp 720w, /content/241201/8.1440w.webp 1440w, /content/241201/8.1680w.webp 1680w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/241201/8.png" alt="npm trends weekly downloads over a year: eslint and prettier sit between 30 and 50 million while biome hugs the bottom of the axis" width="1680" height="854" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>According to npm trends, Biome's weekly downloads—about 6.9 million—still lag far behind ESLint's roughly 120 million and Prettier's roughly 82 million. But Biome's growth rate is noteworthy. In just over a year, weekly downloads have increased more than three- to fourfold, with adoption in new projects rising particularly noticeably.</p>
<h2 id="closing-thoughts"><a class="anchor" href="#closing-thoughts">Closing Thoughts</a></h2>
<p>My answer to whether Biome can completely replace ESLint and Prettier is <strong>"not yet, but it is a highly credible alternative."</strong></p>
<p>Its performance is exceptional, its configuration is concise, and its development pace is fast. However, the immature plugin ecosystem and limitations in support for certain languages may become obstacles depending on the project. It is best to closely review your project's technology stack and your team's requirements before deciding whether to adopt it.</p>
<p>One thing is certain: the frontend tooling ecosystem is moving toward tools that are "faster, simpler, and more integrated." There is no denying that Biome is at the forefront of that movement. It is clearly a tool whose future growth is worth watching.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>JavaScript</category>
        </item>
        <item>
            <title><![CDATA[Zustand, What Makes You ProviderLess?]]></title>
            <link>https://hooninedev.com/en/240818</link>
            <guid isPermaLink="false">https://hooninedev.com/en/240818</guid>
            <pubDate>Sun, 18 Aug 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to explore how Zustand manages state without a Provider. While using Zustand, I had always taken it for granted that I could manage state without a Provider. Then a question sudde...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to explore how Zustand manages state without a Provider.</p>
<p>While using Zustand, I had always taken it for granted that I could manage state without a Provider. Then a question suddenly occurred to me. In most libraries across the React ecosystem, wrapping the app in a Provider has become almost ritualistic. TanStack React Query requires a <code>QueryClientProvider</code> before you can use <code>useQuery</code>, and toss's overlay-kit requires an <code>OverlayProvider</code> before it can call <code>overlay.open()</code>. React's Context API likewise requires the component tree to be wrapped in a Provider. So what kind of magic lets Zustand avoid that entire process?</p>
<p>Out of curiosity, I dug directly into Zustand's source code and found a more interesting structure than I had expected. This post organizes what I learned along the way.</p>
<h2 id="how-does-state-flow-in-react"><a class="anchor" href="#how-does-state-flow-in-react">How Does State Flow in React?</a></h2>
<p>In a typical React application, state works as shown below.</p>
<p><picture><source type="image/webp" srcset="/content/240818/3.720w.webp 720w, /content/240818/3.880w.webp 880w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/3.png" alt="React data flow diagram: data flows down through props while events travel back up" width="880" height="509" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>State inside a component is managed with the state management hooks React provides (<code>useState</code>, <code>useReducer</code>). State is then passed to child components through props. So far, this is straightforward.</p>
<p>The problem arises when state must be shared between components that are far apart. React's official solution is the Context API, which requires wrapping the subtree in a Provider component.</p>
<h3 id="why-does-the-context-api-need-a-provider"><a class="anchor" href="#why-does-the-context-api-need-a-provider">Why Does the Context API Need a Provider?</a></h3>
<p>To answer this question, we need to look briefly at React's internals.</p>
<p>React manages the component tree using an internal data structure called Fiber. Each Fiber node is connected through parent-child relationships, and when a Context value changes, React traverses this Fiber tree from top to bottom, finds the components subscribed to that Context, and triggers them to rerender.</p>
<p>The key is this: <strong>Context value propagation depends on the structure of the Fiber tree.</strong> The Provider's position in the tree determines the scope across which its value is delivered, and a component that calls <code>useContext</code> walks up its own Fiber ancestry to find the nearest Provider. What if there is no Provider? It simply uses the default value passed to <code>createContext</code>.</p>
<p>In other words, the Context API is tightly coupled to React's rendering system. State storage, propagation, and subscription all happen inside React's component tree.</p>
<p>How, then, does Zustand bypass this structure?</p>
<h2 id="zustand-lives-outside-react"><a class="anchor" href="#zustand-lives-outside-react">Zustand Lives Outside React</a></h2>
<p><picture><source type="image/webp" srcset="/content/240818/4.720w.webp 720w, /content/240818/4.1300w.webp 1300w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/4.png" alt="Flux pattern diagram: Action, Dispatcher, Store, and View connected in one direction" width="1300" height="393" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Zustand is based on the Flux pattern. The <code>state</code> inside a closure acts as the Store, user-defined functions act as Actions, the <code>set</code> function acts as the Dispatcher, and React components act as Views. This is where the decisive difference appears.</p>
<p><strong>A Zustand Store exists outside the React component tree, within the scope of a JavaScript module.</strong> Unlike state managed inside React, the phrase "outside the component tree," often used when discussing Zustand, means that the state exists independently of React's Fiber tree. Any component can access the Store simply by using <code>import</code>, with no need to wrap the app in a Provider. (It is accessible from anywhere like a global variable, while still being safely protected inside a closure.)</p>
<p>How is this possible? Consider the code below.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { create } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> useStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> create</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  count: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  increment</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ count: state.count </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> })),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}));</span></span></code></pre></figure>
<p>In this code, <code>create</code> is called when the module is loaded. In other words, the Store already exists in memory before React even begins rendering. This is the <strong>module-level singleton pattern</strong>.</p>
<h3 id="what-is-a-module-level-singleton"><a class="anchor" href="#what-is-a-module-level-singleton">What Is a Module-Level Singleton?</a></h3>
<p>JavaScript's ES module system <strong>evaluates a module only once and caches the result</strong>. Any subsequent <code>import</code> of that module returns the same cached object instead of executing the module again. In other words, whether component A or component B uses <code>import { useStore } from './store'</code>, both refer to <strong>the exact same Store instance</strong>.</p>
<p>There is no need to implement a separate singleton class or attach anything to a global variable (<code>window.store</code>). The module system itself naturally satisfies the singleton requirements of "created only once and accessible as the same instance from anywhere." Zustand directly leverages this language-level guarantee, enabling every component to share a single Store without a separate Provider.</p>
<p>At this point, one question naturally follows: what exactly does Zustand look like internally?</p>
<h2 id="zustands-internal-structure"><a class="anchor" href="#zustands-internal-structure">Zustand's Internal Structure</a></h2>
<p>Looking through <a href="https://github.com/pmndrs/zustand/tree/main/src" target="_blank" rel="noopener noreferrer">Zustand's GitHub repository</a>, its core logic is surprisingly concise. Two files are central: <code>vanilla.ts</code> contains the Store itself, while <code>react.ts</code> provides the bridge to React.</p>
<h3 id="vanillats"><a class="anchor" href="#vanillats">vanilla.ts</a></h3>
<p><a href="https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts" target="_blank" rel="noopener noreferrer">vanilla.ts</a> is the heart of Zustand. Everything about how a Store is created and how its state is managed is contained in this one file. Put more simply, it defines the state enclosed in a closure and the functions that manipulate that state.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createStoreImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> CreateStoreImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReturnType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> createState></span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Listener</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">prevState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> void</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> listeners</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">Listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">> </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> setState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'setState'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">partial</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">replace</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> nextState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> partial </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'function'</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (partial </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)(state)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> partial</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">is</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(nextState, state)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> previousState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        (replace </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">??</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> nextState </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!==</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'object'</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> ||</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> nextState </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">          ?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (nextState </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">          :</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assign</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">({}, state, nextState)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      listeners.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">forEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(state, previousState))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'getState'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> getInitialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'getInitialState'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    initialState</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> subscribe</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'subscribe'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">listener</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    listeners.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">add</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(listener)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> listeners.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">delete</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(listener)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { setState, getState, getInitialState, subscribe }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> initialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setState, getState, api))</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> api </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>Breaking down this code line by line reveals Zustand's core mechanisms.</p>
<h4 id="state-encapsulation-through-a-closure"><a class="anchor" href="#state-encapsulation-through-a-closure">State encapsulation through a closure</a></h4>
<ul>
<li>
<p>The variable <code>let state: TState</code> is declared locally inside <code>createStoreImpl</code>. Even after the function finishes executing, internal functions such as <code>setState</code> and <code>getState</code> continue to reference it, so it is not garbage-collected. This is the essence of a closure.</p>
</li>
<li>
<p>External code has no way to access the <code>state</code> variable directly. It can only read it through <code>getState()</code> and write it through <code>setState()</code>. (This is effectively an object-oriented private field implemented with a closure.)</p>
</li>
</ul>
<h4 id="change-detection-with-objectis"><a class="anchor" href="#change-detection-with-objectis">Change detection with <code>Object.is</code></a></h4>
<ul>
<li>
<p>After calculating the next state, <code>setState</code> compares it with the existing state using <code>Object.is(nextState, state)</code>. If the references are identical, nothing happens. This is the first line of defense against unnecessary rerenders.</p>
</li>
<li>
<p>However, because this <code>Object.is</code> comparison performs a <strong>strict referential equality</strong> check, consumers need to be careful in one particular area. There is no issue when selecting a single primitive value, such as a number or string.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> count</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> state.count);</span></span></code></pre></figure>
<p>But the situation changes when a selector <strong>returns a new object</strong>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">count</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  count: state.count,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  name: state.name,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}));</span></span></code></pre></figure>
<p>Even when the values are identical, the <code>{ count, name }</code> object receives a new reference on every call. Because <code>Object.is</code> compares only the reference rather than the object's properties, Zustand considers the state "changed" and triggers a rerender every time.</p>
<p>To solve this problem, Zustand provides the <strong><code>useShallow</code></strong> hook.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { useShallow } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand/react/shallow'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">count</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">name</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  useShallow</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({ count: state.count, name: state.name }))</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span></code></pre></figure>
<p><code>useShallow</code> compares the returned object's <strong>top-level properties one by one</strong>, triggering a rerender only when a value actually changes. This is similar to how Redux's <code>useSelector</code> uses reference comparison by default but accepts <code>shallowEqual</code> as its second argument. (As its name suggests, however, <code>useShallow</code> performs a "shallow" comparison, so remember that it does not track changes inside nested objects.)</p>
</li>
</ul>
<h4 id="a-pubsub-listener-system"><a class="anchor" href="#a-pubsub-listener-system">A Pub/Sub listener system</a></h4>
<ul>
<li>The single line <code>const listeners: Set&#x3C;Listener> = new Set()</code> constitutes Zustand's entire subscription system. When state changes, <code>listeners.forEach</code> notifies every subscriber.</li>
<li>Calling <code>subscribe</code> adds a listener to the <code>Set</code>, and calling the returned function removes it from the <code>Set</code>.</li>
<li>This pattern matters because it is <strong>a notification system completely independent of React's Fiber tree</strong>. Rather than having a Provider traverse the tree to find subscribers, the Store manages its subscriber list directly.</li>
</ul>
<h4 id="creating-the-initial-state"><a class="anchor" href="#creating-the-initial-state">Creating the initial state</a></h4>
<ul>
<li>
<p>Consider the final line that handles the initial state.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> initialState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (state </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(setState, getState, api))</span></span></code></pre></figure>
<p>A great deal is compressed into this one line. In JavaScript, the assignment operator (<code>=</code>) is an expression that <strong>returns the assigned value itself</strong>. This means <code>state = createState(...)</code> inside the parentheses runs first and assigns the initial state to <code>state</code>, after which its return value is assigned again to <code>const initialState</code>. As a result, <code>state</code> and <code>initialState</code> <strong>refer to the same object</strong>.</p>
<p>Why store the same value in two separate variables? The key is that the variables have different roles.</p>
<ul>
<li><strong><code>state</code></strong> is declared with <code>let</code>. It is replaced with a new value every time <code>setState</code> is called. In other words, it represents <strong>the live state at the current point in time</strong>.</li>
<li><strong><code>initialState</code></strong> is declared with <code>const</code>. It permanently preserves the state from the moment the Store was created. No later call to <code>setState</code> changes this value. It is <strong>the Store's original snapshot</strong>.</li>
</ul>
<p>This <code>initialState</code> is exposed through the <code>getInitialState()</code> method and passed in <code>react.ts</code> as the <strong>third argument (the server snapshot)</strong> to <code>useSyncExternalStore</code>.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> slice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSyncExternalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  api.subscribe,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()),       </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  () </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), </span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span></code></pre></figure>
<p>In a server-side rendering (SSR) environment, browser APIs are unavailable and there is no user interaction, so <code>setState</code> is never called. The server therefore always uses <code>initialState</code> (= the original state) as its snapshot. When hydration begins on the client, React compares the HTML rendered on the server with the client's initial rendering result. Because both sides rendered from the same <code>initialState</code>, this <strong>prevents a hydration mismatch</strong>.</p>
</li>
</ul>
<h3 id="reactts"><a class="anchor" href="#reactts">react.ts</a></h3>
<p><a href="https://github.com/pmndrs/zustand/blob/main/src/react.ts" target="_blank" rel="noopener noreferrer">react.ts</a> connects the pure JavaScript Store created above to React's rendering system.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">export</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">StateSlice</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReadonlyStoreApi</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>,</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> TState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StateSlice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> identity </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">as</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> slice</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useSyncExternalStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    api.subscribe,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), [api, selector]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useCallback</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> selector</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()), [api, selector]),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  )</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  React.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">useDebugValue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(slice)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> slice</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>The key here is <code>useSyncExternalStore</code>. Introduced in React 18, this hook was designed to <strong>safely integrate a state store that exists outside React with React's rendering cycle</strong>.</p>
<p>Its structure becomes clear when we examine the three arguments accepted by <code>useSyncExternalStore</code>. (They are almost identical to what we covered earlier in vanilla.ts.)</p>
<ul>
<li><strong><code>api.subscribe</code></strong>: The function that subscribes to Store changes. Through this function, React asks to be notified when the state changes.</li>
<li><strong><code>() => selector(api.getState())</code></strong>: Returns a snapshot of the current state. React calls this function on every render to retrieve the latest state.</li>
<li><strong><code>() => selector(api.getInitialState())</code></strong>: The initial snapshot used during server-side rendering. It prevents server and client state from diverging during hydration.</li>
</ul>
<p>In particular, <code>useSyncExternalStore</code> solves the <strong>tearing problem</strong> that can arise in React's Concurrent Mode. Tearing occurs when different components show <strong>different snapshots of the same data source</strong> within a single render pass.</p>
<p>A concrete scenario makes this easier to understand. Component A reads <code>store.value</code> (= 10) and begins rendering. In Concurrent Mode, React then <strong>yields</strong>, pausing the render and returning control to the browser. In the meantime, a WebSocket message arrives and changes <code>store.value</code> to 11. When React resumes rendering, component B reads <code>store.value</code> (= 11). The result is a <strong>teared UI</strong> in which A displays 10 and B displays 11 in the same frame. Before React 18, rendering was always synchronous, so this problem did not occur.</p>
<p><code>useSyncExternalStore</code> records the snapshot (<code>getSnapshot</code>) from the start of the render. If the external Store changes during rendering and the snapshot no longer matches, it detects the change and <strong>restarts the render from the beginning</strong>. This guarantees that every component renders from the same snapshot.</p>
<p>The <code>createImpl</code> function ties all of this together.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createImpl</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">createState</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> StateCreator</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, [], []>) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> api</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(createState)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useBoundStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> any</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(api, selector)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  Object.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">assign</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(useBoundStore, api)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> useBoundStore</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span></code></pre></figure>
<p>It creates a vanilla Store with <code>createStore</code>, wraps it in a custom hook called <code>useBoundStore</code>, and then uses <code>Object.assign</code> to attach the Store API methods (<code>setState</code>, <code>getState</code>, <code>subscribe</code>, and so on) directly to the hook function. The returned <code>useBoundStore</code> consequently has a dual nature: it is <strong>both a React hook and the Store API</strong>. (A function that also has methods is a distinctly JavaScript-like pattern.)</p>
<h2 id="what-about-other-state-management-libraries"><a class="anchor" href="#what-about-other-state-management-libraries">What About Other State Management Libraries?</a></h2>
<p>Now that we understand this much, it is natural to compare Zustand with other libraries.</p>
<p>There are many state management libraries, including Jotai, Recoil, MobX, Xstate, and Redux, but I will focus on the ones I have personally used.</p>
<blockquote>
<p>For reference, <strong>Recoil</strong> (Meta), which was often compared with Jotai, was effectively discontinued when its repository was archived in January 2025. It also never received React 19 support. If you want an atomic state model, Jotai is arguably the only practical choice today.</p>
</blockquote>
<h3 id="redux"><a class="anchor" href="#redux">Redux</a></h3>
<p>Redux also uses a module-level Store internally. Why, then, does it need a Provider?</p>
<p>Redux's <code>&#x3C;Provider store={store}></code> <strong>injects</strong> the Store instance into the component tree through React Context. Internally, <code>useSelector</code> and <code>useDispatch</code> call <code>useContext</code> to access the Store supplied by the Provider. The important point is that Redux uses Context <strong>not as a state propagation channel, but as a means of Dependency Injection</strong>. What Context carries is not the state value itself, but a <strong>reference to the Store object</strong> that manages the state. Actual state subscriptions and updates are handled by the Store's internal Pub/Sub system.</p>
<p>The benefits of this design are clear. Tests can be completely isolated by wrapping them in a Provider with a different Store instance, and a single app can use the <code>context</code> prop to construct multiple independent Store trees. As Mark Erikson, a Redux maintainer, emphasizes, "Context is a transport mechanism, not a state management tool."</p>
<h3 id="jotai"><a class="anchor" href="#jotai">Jotai</a></h3>
<p>Jotai adopts an <strong>atomic state model</strong> that is fundamentally different from Redux or Zustand. Rather than collecting state in one large Store object, it <strong>separates each piece of state into an independent atom</strong>. (Jotai's official documentation likewise explains that "Zustand is similar to Redux, while Jotai is similar to Recoil.")</p>
<p>The central difference in this structure is <strong>how rendering is optimized</strong>. Zustand takes a <strong>top-down</strong> approach, extracting only the required portion from a single Store through a selector. Developers must write selectors themselves, as in <code>useStore((state) => state.count)</code>, and memoization is sometimes necessary to preserve referential equality. Jotai, by contrast, automatically builds a <strong>dependency graph</strong> among atoms and performs <strong>bottom-up</strong> propagation: when a particular atom changes, only the components that depend on that atom rerender. This automatic dependency tracking is especially powerful when dozens of pieces of state are intertwined, as in a spreadsheet or canvas editor.</p>
<p>From a Provider perspective, Jotai occupies an interesting middle ground. It uses a global Store by default and works without a Provider, but it can also be wrapped in <code>&#x3C;Provider></code> to create an isolated Store scope when needed. Borrowing the wording of Jotai's official documentation, Jotai is <strong>"context first, module second,"</strong> whereas Zustand is <strong>"module first, context second."</strong></p>
<h3 id="zustands-choice"><a class="anchor" href="#zustands-choice">Zustand's Choice</a></h3>
<p>Zustand made the most radical choice. By default, it is a module-level singleton and has no Provider at all. What this choice delivers is an <strong>extremely simple API</strong>: create a Store with <code>create</code>, call the hook from a component, and you are done.</p>
<p>Strictly speaking, however, "there is no Provider at all" describes only the <strong>default design</strong>. Since v4, you can combine <code>createStore</code> (a vanilla Store) with React's <code>createContext</code> to implement the <strong>Scoped Store</strong> pattern.</p>
<p><a href="https://tkdodo.eu/blog/zustand-and-react-context" target="_blank" rel="noopener noreferrer">TkDodo's blog (the React Query maintainer)</a> explores this pattern in depth. His central argument is that a global singleton Store has three limitations.</p>
<ul>
<li><strong>It cannot be initialized from props</strong>: Because the Store is created when the module loads, there is no way to use server-provided data or a parent component's props as initial values.</li>
<li><strong>Test isolation is difficult</strong>: The Store must be reset manually for every test.</li>
<li><strong>It cannot be reused</strong>: If two components that need Stores with the same structure are rendered on one page, they end up sharing state.</li>
</ul>
<p>The Scoped Store pattern solves all three problems. Its central idea is to pass <strong>a reference to the Store instance through Context, rather than the state value</strong>. (This is exactly the same structure used by Redux's Provider.)</p>
<p>Here is a concrete implementation.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createStore, useStore } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zustand'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">import</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { createContext, useContext, useState } </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">from</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'react'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 1. 스토어 팩토리 함수 — props를 받아 스토어를 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createSelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">  createStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">SelectionState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    items: initialItems,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    selected: </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(),</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">    toggle</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">: (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">id</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">      set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> next</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(state.selected);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">        next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">has</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">delete</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> next.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">add</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(id);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">        return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { selected: next };</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      }),</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }));</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 2. Context 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">type</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> ReturnType</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">typeof</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> createSelectionStore>;</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> SelectionContext</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">SelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> |</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">>(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">null</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 3. Provider — useState로 스토어를 한 번만 생성</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionProvider</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> ({</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  children,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  initialItems,</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  children</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> React</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">ReactNode</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">;</span></span>
<span data-line=""><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">  initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> string</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">[];</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">store</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> createSelectionStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(initialItems));</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    &#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SelectionContext.Provider value</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{store}</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      {</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">children</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    &#x3C;/</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">SelectionContext.Provider</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  );</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 4. 커스텀 훅 — Context에서 스토어를 꺼내 useStore로 구독</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useSelectionStore</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> &#x3C;</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">,>(</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">selector</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">state</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> SelectionState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> T</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> store</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useContext</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(SelectionContext);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">!</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">store) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">throw</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Error</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'SelectionProvider가 필요합니다'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">);</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> useStore</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(store, selector);</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">};</span></span></code></pre></figure>
<p>You can now render as many independent multi-select components as you want on the same page.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="tsx" data-theme="github-dark github-light"><code data-language="tsx" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 각 SelectionProvider가 자신만의 스토어 인스턴스를 가진다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'A'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'B'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'C'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MultiSelect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> /></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> initialItems</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">{[</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'X'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Y'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'Z'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]}></span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  &#x3C;</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">MultiSelect</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> />  {</span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">/* 위 컴포넌트와 상태가 완전히 독립 */</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">&#x3C;/</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">SelectionProvider</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">></span></span></code></pre></figure>
<p>The point to notice is that Context carries <strong>the Store object, not the state value</strong>. Because the Context <code>value</code> (= the Store reference) does not change when the state value changes, <strong>changes to the Context value do not cause unnecessary rerenders.</strong> For actual rerendering, <code>useStore</code> uses its internal <code>useSyncExternalStore</code> with the selector. Context's transport role and Zustand's subscription role remain cleanly separated.</p>
<p>TkDodo described a real-world application of this pattern in a design system's multi-select component. The previous architecture, which managed internal state with <code>useState</code> + Context, suffered performance degradation with more than 50 items; switching to Zustand's selector-based subscription solved the problem.</p>
<p>After v4 removed <code>zustand/context</code> and its <code>createContext</code> helper from v3, this pattern settled on <strong>directly combining React's native <code>createContext</code> with Zustand's <code>createStore</code>/<code>useStore</code></strong>. The API remains unchanged in v5, and <a href="https://github.com/pmndrs/zustand/blob/main/docs/previous-versions/zustand-v3-create-context.md" target="_blank" rel="noopener noreferrer">Zustand's official documentation</a> presents this pattern in its v4+ migration guide.</p>
<h2 id="the-shadow-side-of-providerless"><a class="anchor" href="#the-shadow-side-of-providerless">The Shadow Side of ProviderLess</a></h2>
<p>Of course, having no Provider does not bring only advantages. Here are the points that I believe require caution.</p>
<h3 id="state-sharing-problems-in-ssr"><a class="anchor" href="#state-sharing-problems-in-ssr">State Sharing Problems in SSR</a></h3>
<p>A module-level singleton can be dangerous in a server environment. A Node.js server handles multiple requests in a single process, while a module is loaded only once within that process. This means that requests from different users may <strong>share the same Store instance</strong>.</p>
<p>This is why Zustand provides <code>getInitialState</code> and passes the server snapshot as the third argument to <code>useSyncExternalStore</code>. Even so, this alone may not fully isolate state between requests. In SSR environments, the recommended approach is therefore to use the Scoped Store pattern mentioned earlier (<code>createStore</code> + React Context) and create a new Store for each request.</p>
<h3 id="the-difficulty-of-test-isolation"><a class="anchor" href="#the-difficulty-of-test-isolation">The Difficulty of Test Isolation</a></h3>
<p>With a Provider-based library, wrapping each test in a different Provider naturally isolates the Store. Zustand's module-level singleton, on the other hand, can leak state between tests. You must explicitly reset the Store in each test's <code>beforeEach</code>. (I once struggled with this issue myself.)</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="typescript" data-theme="github-dark github-light"><code data-language="typescript" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 테스트 파일에서의 스토어 리셋 예시</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">beforeEach</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(() </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> {</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  useStore.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">setState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(useStore.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">getInitialState</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">());</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">});</span></span></code></pre></figure>
<p>The Scoped Store pattern is also a solution here. When using a Provider, each test can create and inject a new Store, providing complete isolation without reset logic.</p>
<h3 id="the-lack-of-multiple-instances"><a class="anchor" href="#the-lack-of-multiple-instances">The Lack of Multiple Instances</a></h3>
<p>If one application needs two independent Stores with the same structure, the Provider pattern can simply wrap them in separate Providers. With a module-level singleton, however, the Store creation function must be called separately to create distinct Store instances. For example, if one page contains two independent tab panels and each must manage its selection state separately, a global singleton cannot express that arrangement naturally.</p>
<p>Once again, the <code>createStore</code> + Context pattern is the answer. When each tab panel component renders its own Provider, it creates a completely independent instance with the same Store structure. Zustand's official documentation also recommends this pattern "when a reusable component needs a Store."</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>To summarize what we have examined, Zustand's ProviderLess design is made possible by the combination of four mechanisms.</p>
<ul>
<li><strong>Module-level singleton</strong>: The Store is created outside the React component tree, within the scope of a JavaScript module.</li>
<li><strong>State encapsulation through a closure</strong>: In <code>vanilla.ts</code>, <code>createStoreImpl</code> encloses the <code>state</code> variable and <code>listeners</code> Set in a closure, preventing external access.</li>
<li><strong>Its own Pub/Sub system</strong>: Instead of traversing the Fiber tree, it directly manages a <code>Set&#x3C;Listener></code> to notify subscribers of state changes.</li>
<li><strong>React integration through <code>useSyncExternalStore</code></strong>: It safely synchronizes changes in the external Store with React's rendering cycle.</li>
</ul>
<p>Ultimately, Zustand asks this question: "Does state really have to live inside React?" Zustand's answer is clear. State can remain outside React, with a bridge added only when needed. That bridge is <code>useSyncExternalStore</code>.</p>
<p>Of course, this approach is not the best choice in every situation. A Provider-based design may be more appropriate for SSR, test isolation, or multiple instances. There is no single correct answer, but understanding the design trade-offs each library has chosen helps you select the right tool for the situation.</p>
<p>I also encourage readers to open the source code of a library they use at least once. You may discover a depth that the official documentation does not reveal.</p>
<p><picture><source type="image/webp" srcset="/content/240818/7.216w.webp 216w" sizes="(max-width: 840px) calc(100vw - 32px), 808px"><img src="/content/240818/7.jpeg" alt="A hand-drawn character saying &#x22;hold on a second!&#x22;" width="216" height="233" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h3 id="one-more-piece-of-news"><a class="anchor" href="#one-more-piece-of-news">One More Piece of News</a></h3>
<p>While researching the material above, I learned that <strong>Zustand v5.0.0 was officially released in October 2024</strong>.</p>
<p>Interestingly, v5 contains almost no new features. New features had already been added throughout v4.x while existing APIs were deprecated, so v5 is primarily a <strong>cleanup release</strong>. The major changes are listed below. (For details, see the <strong><a href="https://github.com/pmndrs/zustand/releases" target="_blank" rel="noopener noreferrer">release page</a></strong> and the <strong><a href="https://zustand.docs.pmnd.rs/reference/migrations/migrating-to-v5" target="_blank" rel="noopener noreferrer">migration guide</a></strong>.)</p>
<ul>
<li>The minimum requirements were raised to <strong>React 18 and TypeScript 4.5 or later</strong>.</li>
<li><strong><code>getServerState</code> was removed</strong>. (It was replaced by the third argument to <code>useSyncExternalStore</code>.)</li>
<li><strong>ES5 support was dropped</strong>.</li>
<li>Support for specifying <strong>a custom equality function was removed</strong> from the <code>create</code> function.</li>
<li>The <strong><code>shallow</code> function was improved</strong> to support iterable objects.</li>
</ul>
<p>When migrating from v4 to v5, the recommended approach is to update to the latest v4 release first. The latest v4 release displays deprecation warnings, so addressing those warnings before upgrading to v5 makes the transition straightforward.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://react.dev/reference/react/useSyncExternalStore" target="_blank" rel="noopener noreferrer">React useSyncExternalStore</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://jotai.org/docs/basics/comparison" target="_blank" rel="noopener noreferrer">Jotai Comparison</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#f59e0b"></span><a href="https://interbolt.org/blog/react-ui-tearing/" target="_blank" rel="noopener noreferrer">InterBolt, Concurrent React, External Stores, and Tearing</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[Understanding Compression Algorithms]]></title>
            <link>https://hooninedev.com/en/240706</link>
            <guid isPermaLink="false">https://hooninedev.com/en/240706</guid>
            <pubDate>Sat, 06 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about software compression algorithms. This post is for developers who upload large build artifacts to a server or storage and are deciding between zip, tar.gz, and tar.zs...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about software compression algorithms.</p>
<p>This post is for developers who upload large build artifacts to a server or storage and are deciding between zip, tar.gz, and tar.zst. By the end, you will know where the major compression formats differ in speed and compression ratio, and you will have criteria for choosing between tar.gz and tar.zst.</p>
<p>I was asked to improve the deployment process for an internal project. Its large build artifacts had to be uploaded to S3, and I quickly felt how directly the size of the build directory affected both upload time and storage costs. That naturally led to a question: how could we compress and upload these artifacts more efficiently?</p>
<p>Once I started researching compression, I found far more options than I expected: zip, gzip, zstd, bzip2, xz, and others. Their names looked similar, but it was surprisingly difficult to find a clear explanation of how they differed and when each one should be used. (I had assumed compression was more or less all the same, but the world is large and so is the number of ways to make files smaller.)</p>
<p>Before the comparison, it is worth noting one shared root. Nearly every mainstream compressor today descends from LZ77, published in 1977. LZ77 treats a fixed range of already processed data, the sliding window, as its dictionary and replaces repeated patterns with a short reference meaning “go back this many characters and copy this many.” A larger window catches repetitions that are farther apart but costs more computation and memory. DEFLATE, used by ZIP and GZIP, adds Huffman coding (entropy coding that gives shorter bit sequences to more frequent symbols) on top of this. How LZ77 parted ways with its sibling LZ78 is covered separately in <a href="/240701" target="_blank" rel="noopener noreferrer">The Difference Between LZ77 and LZ78</a>.</p>
<p>Compression performance is usually evaluated on two axes: <strong>compression ratio</strong>, or how small the result becomes, and <strong>compression speed</strong>, or how quickly the operation completes. Seeking a higher ratio generally requires more computation and therefore more time. A practical compression strategy is about finding the right point between the two.</p>
<p>With that foundation, let us compare the major compression formats one by one.</p>
<h2 id="zip"><a class="anchor" href="#zip">ZIP</a></h2>
<p>ZIP is a file format created by Phil Katz in 1989. Internally, it commonly compresses data with <strong>DEFLATE</strong>, the combination of LZ77 and Huffman coding. The important distinction is that ZIP is not itself a compression algorithm. It is a container format that can hold data compressed by an algorithm such as DEFLATE.</p>
<p>ZIP <strong>compresses each file independently</strong>. This structure, in which every file is compressed separately, is called a non-solid archive. The opposite approach, which joins all files into one stream and compresses it at once, is a solid archive. Thanks to the non-solid structure, it is possible to extract a single file without decompressing the rest of the archive. On the other hand, it cannot exploit duplicate data across files, so its compression ratio may be lower than that of tar.gz, which we will examine later.</p>
<p>Because Windows, macOS, Linux, and most other operating systems support ZIP without additional software, it is the safest general choice when cross-platform compatibility matters.</p>
<h2 id="gzip-gnu-zip"><a class="anchor" href="#gzip-gnu-zip">GZIP (GNU Zip)</a></h2>
<p>Like ZIP, GZIP uses <strong>DEFLATE</strong> internally. Why does a separate format exist if the algorithm is the same? ZIP also acts as a container for multiple files, whereas GZIP specializes in compressing <strong>one file or one stream</strong>.</p>
<p>To compress multiple files or a directory with GZIP, we first combine them into a single TAR archive and then compress that archive with GZIP. This two-step process produces a <code>.tar.gz</code> or <code>.tgz</code> file.</p>
<p>The GZIP file structure is specified in RFC 1952 and is quite simple: a <strong>fixed 10-byte header</strong>, an optional extended header containing information such as the original filename or comments, the DEFLATE-compressed data, and an <strong>8-byte trailer</strong> containing a CRC-32 checksum and the original size. CRC-32 verifies that the decompressed data matches the original. GZIP is therefore a lightweight wrapper around a DEFLATE stream.</p>
<p>DEFLATE uses a sliding window of at most <strong>32 KB</strong>. This limit is important because patterns more than 32 KB apart cannot refer to one another. GZIP also provides compression levels from 1 to 9. Level 1 is fast but produces a lower ratio of roughly 60%, while level 9 is slow but reaches a higher ratio of roughly 75%. The default is level 6, a compromise between speed and size.</p>
<p>GZIP has long been a standard for distributing source code, compressing logs, and packaging software in Unix and Linux environments. It also remains a common default for HTTP compression through <code>Content-Encoding: gzip</code>, although Brotli has increasingly replaced it in that role.</p>
<h2 id="zstd-zstandard"><a class="anchor" href="#zstd-zstandard">ZSTD (Zstandard)</a></h2>
<p>ZSTD is a compression algorithm developed by Yann Collet at Meta, formerly Facebook, and released as open source in 2016. Its main advantage is <strong>dramatically faster compression and decompression while retaining a ratio comparable to GZIP</strong>.</p>
<p>ZSTD has three broad stages. First, an LZ77-family <strong>match finder</strong> detects repeated patterns in the input. It then encodes the results, including literals, match lengths, and offsets, as <strong>sequences</strong>. Finally, it compresses those sequences with <strong>entropy coding</strong>. Instead of GZIP-style Huffman coding, ZSTD uses <strong>FSE (Finite State Entropy)</strong>. FSE is an entropy coder based on ANS (Asymmetric Numeral Systems). ANS updates a single integer state each time it processes a symbol and emits fewer bits for more frequent symbols. Its goal is a compression ratio close to arithmetic coding at a speed close to Huffman coding. Huffman coding can assign only an integer number of bits per symbol, while FSE can represent fractional-bit probabilities and get closer to the theoretical optimum. (Despite the grand name, the key idea is simply a smarter way to express the same data with fewer bits.)</p>
<p>The match finder also changes strategy with the compression level. Lower levels, from 1 to 4, use simple hash tables for speed. Middle levels, from 5 to 12, compare multiple candidates and lazily choose a better match. Higher levels, from 13 to 22, use binary trees and dynamic programming to find near-optimal matches. This broad range makes it possible to choose a low level for real-time transfer and a high level for archival work.</p>
<p>On the Silesia Corpus benchmark, ZSTD’s default level 3 compresses at around 300 MB/s and decompresses at around 1,200 MB/s. GZIP’s default level 6 reaches only around 34 MB/s for compression and 380 MB/s for decompression. <strong>ZSTD is roughly eight times faster at compression and three times faster at decompression, while its compression ratio is even ahead at 3.17 versus GZIP’s 3.09.</strong> These figures make ZSTD’s improved trade-off easy to see.</p>
<p>Adoption has expanded quickly. ZSTD is used for Linux kernel module compression and transparent filesystem compression, and major distributions including Arch Linux, Fedora, Debian, and Ubuntu have adopted it as a default package format. Starting with v1.5.7, released in February 2025, <strong>multithreaded compression is enabled by default</strong> with up to four threads, further widening the practical speed gap with single-threaded GZIP. AWS has also reported reducing S3 storage by about 30% after switching internal services from gzip to zstd.</p>
<h2 id="bzip2"><a class="anchor" href="#bzip2">BZIP2</a></h2>
<p>BZIP2 compresses data through a pipeline of transformations.</p>
<ol>
<li><strong>RLE (Run-Length Encoding)</strong>: reduces consecutive repetitions in the initial data</li>
<li><strong>BWT (Burrows-Wheeler Transform)</strong>: rearranges data into a form that is easier to compress</li>
<li><strong>MTF (Move-to-Front Transform)</strong>: converts the BWT output into a numeric sequence</li>
<li><strong>RLE</strong>: reduces repetitions in the MTF result again</li>
<li><strong>Huffman coding</strong>: performs the final frequency-based encoding</li>
</ol>
<p>BZIP2 offers a higher compression ratio than GZIP, but both compression and decompression are slower. It has traditionally been used for archival work where size matters more than speed.</p>
<p>Its latest release was v1.0.8 in 2019, and it is not under active development. As benchmarks increasingly show ZSTD outperforming BZIP2 in both ratio and speed, new projects are more likely to choose ZSTD.</p>
<h2 id="xz"><a class="anchor" href="#xz">XZ</a></h2>
<p>XZ is a compression format that uses <strong>LZMA2</strong>. LZMA, the Lempel-Ziv-Markov chain Algorithm developed by Igor Pavlov, combines LZ77-based dictionary compression with range encoding. Rather than simply being an “improved LZMA,” LZMA2 is closer to a <strong>container format</strong> around LZMA streams. Its key additions include multithreaded compression and decompression and efficient handling of incompressible data.</p>
<p>Among the formats discussed here, XZ provides <strong>the highest compression ratio</strong>. The cost is very slow compression and high memory consumption. It is well suited to archival work where minimizing storage is the top priority.</p>
<p>In March 2024, however, a <strong>backdoor was discovered in xz-utils, XZ’s core library, in the severe CVE-2024-3094 supply-chain incident</strong>. A two-year social-engineering campaign had obtained maintainer privileges, and the vulnerability received the maximum CVSS score of 10.0. Major distributions immediately rolled back to safe versions, but the incident became a powerful warning about open-source supply-chain security. (XZ’s technical value remains, but this context is worth considering when choosing tools.)</p>
<h2 id="tar"><a class="anchor" href="#tar">TAR</a></h2>
<p>TAR, short for Tape Archive, is not a compression algorithm. It is a tool and format for <strong>combining multiple files and directories into one archive</strong>. As its name suggests, it was originally designed for tape backups. Since tape is sequential media, appending data in a continuous sequence was a natural design.</p>
<p>TAR’s internal structure is surprisingly simple. Everything is processed in <strong>512-byte blocks</strong>. Each file begins with a 512-byte header containing metadata such as its name, up to 100 bytes, file mode, owner UID and GID, size, modification time, and checksum. File data follows the header and is padded to a multiple of 512 bytes. Two zero-filled 512-byte blocks mark the end of the archive. Most modern TAR implementations follow the POSIX <strong>UStar (Unix Standard TAR)</strong> format, which supports longer filenames of up to 256 bytes and additional metadata fields.</p>
<p>The key property is that TAR preserves <strong>Unix filesystem metadata</strong>, including permissions, ownership, timestamps, and symbolic links. ZIP does not always preserve this Unix-specific metadata perfectly, which often makes TAR a better fit for server deployment.</p>
<p>TAR does not make data smaller by itself. In fact, its headers and padding make the result slightly larger than the original. Compression is performed by combining TAR with GZIP, BZIP2, XZ, ZSTD, or another compressor. That is why extensions such as <code>.tar.gz</code>, <code>.tar.bz2</code>, <code>.tar.xz</code>, and <code>.tar.zst</code> exist. TAR handles “bundling,” while the compression tool handles “shrinking,” a classic example of the Unix philosophy to “do one thing well.”</p>
<p>TAR is standard in Unix and Linux environments, while Windows may require additional software such as 7-Zip.</p>
<h2 id="a-quick-look-at-brotli"><a class="anchor" href="#a-quick-look-at-brotli">A quick look at Brotli</a></h2>
<p>Frontend developers should also understand <strong>Brotli</strong>. Google developed Brotli, and it was standardized for HTTP stream compression as <code>Content-Encoding: br</code> in 2015.</p>
<p>Every major browser supports it over HTTPS, with more than 96% global coverage, and it generally produces files <strong>about 15 to 25% smaller than GZIP</strong>. It is particularly effective for text-based static assets such as JavaScript, CSS, and HTML. Major CDNs including Cloudflare use it as a default compression method, and the modern web-optimization pattern is “Brotli first, GZIP fallback.”</p>
<p>If build artifacts are uploaded to S3 and served through a CDN, precompressing static files with Brotli can substantially reduce network transfer. (There was not enough project-specific evidence for me to introduce it immediately, but it remains an option worth understanding and revisiting.)</p>
<h2 id="why-does-targz-compress-better-than-zip"><a class="anchor" href="#why-does-targz-compress-better-than-zip">Why does tar.gz compress better than ZIP?</a></h2>
<p>The difference comes from <strong>solid archives</strong> and <strong>non-solid archives</strong>.</p>
<p>With tar.gz, TAR first combines every file into one continuous data stream, and GZIP compresses that entire stream at once. This allows it to detect and exploit <strong>duplicate data across files</strong>. That is a solid archive. If a build directory contains dozens of similarly structured JavaScript bundles, a pattern in file A can be referenced when it appears again in file B. Metadata overhead is also lower because the compressor does not need a separate header, checksum, and table of contents for every compressed stream.</p>
<p>ZIP is non-solid and compresses each file independently, so it cannot use redundancy across files. Even if files A and B contain the same code block, their separate DEFLATE streams do not know about one another. This is why tar.gz commonly achieves a compression ratio 5 to 15% better than ZIP. The difference becomes larger when a build contains many files with similar structures.</p>
<p>Solid archives also have clear disadvantages.</p>
<ul>
<li>To extract one file, the decoder may need to <strong>decompress all data that appears before it</strong>. Because every file belongs to one stream, it cannot simply jump to an arbitrary point. ZIP supports random access to individual files and may be more suitable when specific files are extracted frequently.</li>
<li>Damage to part of the archive can make <strong>all data after the damaged point unrecoverable</strong>. A non-solid archive may lose only the damaged file while preserving the rest.</li>
</ul>
<h2 id="the-choice-in-2024-was-targz-what-would-i-choose-now"><a class="anchor" href="#the-choice-in-2024-was-targz-what-would-i-choose-now">The choice in 2024 was tar.gz. What would I choose now?</a></h2>
<p>I added this section in 2026. I chose tar.gz at the time because of its compatibility and stability. The artifact had to be decompressed in several environments after being uploaded to S3, so the format supported almost everywhere was the safest option.</p>
<p>If I faced the same situation today, however, I would seriously consider <strong>tar.zst (TAR + ZSTD)</strong>. Recall the benchmark figures above.</p>
<p>GZIP’s default compression speed is 34 MB/s, while ZSTD’s default is 300 MB/s. For a 2 GB build directory, a simple calculation gives about 60 seconds for GZIP and around seven seconds for ZSTD. With multithreading enabled by default in ZSTD v1.5.7, using as many as four threads, the time it actually takes is shorter still. In a CI/CD pipeline, this difference accumulates on every deployment cycle, so it is not a number you can ignore.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="sh" data-theme="github-dark github-light"><code data-language="sh" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># tar.zst 생성 (멀티스레드 자동 활용)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">tar</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> --zstd</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -cf</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> archive.tar.zst</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> directory/</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D"># 또는 압축 레벨 지정 (-T0은 사용 가능한 모든 코어 활용)</span></span>
<span data-line=""><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">tar</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -cf</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> archive.tar.zst</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> -I</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'zstd -3 -T0'</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> directory/</span></span></code></pre></figure>
<p>ZSTD also matches or surpasses GZIP’s compression ratio, so there is effectively no longer a trade-off where speed requires accepting a larger artifact. It is both faster and smaller.</p>
<p>The receiving environment must still be able to decompress zstd. Major Linux distributions include it, and macOS users can install it easily with Homebrew using <code>brew install zstd</code>. Legacy or minimal environments may require a separate installation, so every environment used by the team should be checked in advance. When compatibility is the overriding concern, tar.gz remains the safest general choice.</p>
<h2 id="comparison-at-a-glance"><a class="anchor" href="#comparison-at-a-glance">Comparison at a glance</a></h2>
<table>
<thead>
<tr>
<th>Format</th>
<th>Algorithm</th>
<th>Ratio</th>
<th>Speed</th>
<th>Key characteristics</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>ZIP</strong></td>
<td>DEFLATE</td>
<td>Medium</td>
<td>Fast</td>
<td>Cross-platform, non-solid</td>
</tr>
<tr>
<td><strong>GZIP</strong></td>
<td>DEFLATE</td>
<td>Medium</td>
<td>Fast</td>
<td>Single stream, commonly with TAR</td>
</tr>
<tr>
<td><strong>ZSTD</strong></td>
<td>Zstandard</td>
<td>High</td>
<td>Very fast</td>
<td>Tunable levels, modern standard</td>
</tr>
<tr>
<td><strong>BZIP2</strong></td>
<td>BWT+MTF+Huffman</td>
<td>High</td>
<td>Slow</td>
<td>Largely inactive development</td>
</tr>
<tr>
<td><strong>XZ</strong></td>
<td>LZMA2</td>
<td>Very high</td>
<td>Very slow</td>
<td>Best ratio, security context</td>
</tr>
<tr>
<td><strong>Brotli</strong></td>
<td>Brotli</td>
<td>High</td>
<td>Medium</td>
<td>Optimized for the web</td>
</tr>
</tbody>
</table>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>Before digging into compression, I honestly thought, “Can’t we just put everything in a zip file?” Working with a build directory larger than 2 GB made the consequences tangible: the algorithm we choose can meaningfully change both upload time and cost.</p>
<p>Every compression format reflects a different design philosophy and trade-off. ZIP offers compatibility, GZIP ubiquity, ZSTD speed, and XZ maximum compression. There is no universal “best” option; the right choice depends on the project’s constraints.</p>
<p>Understanding the principles behind tools we usually take for granted helps us make better decisions the next time a similar problem appears. I hope this article serves as a useful starting point for someone facing that choice.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>curiosities</category>
            <category>software</category>
        </item>
        <item>
            <title><![CDATA[The Difference Between LZ77 and LZ78]]></title>
            <link>https://hooninedev.com/en/240701</link>
            <guid isPermaLink="false">https://hooninedev.com/en/240701</guid>
            <pubDate>Mon, 01 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[In this post, I want to talk about how LZ77 and LZ78 differ. This post is for developers who use tools like zip, gzip, and zstd and have wondered how the dictionary-based compression inside them works...]]></description>
            <content:encoded><![CDATA[<p>In this post, I want to talk about <strong>how LZ77 and LZ78 differ</strong>.</p>
<p>This post is for developers who use tools like zip, gzip, and zstd and have wondered how the dictionary-based compression inside them works. By the end, you will be able to explain how the two algorithms differ in handling their dictionaries and which of the two today's mainstream compressors descend from.</p>
<p>If you compare formats for compressing build artifacts, you eventually end up tracing them back to these two algorithms.</p>
<h2 id="dictionary-based-compression"><a class="anchor" href="#dictionary-based-compression">Dictionary-based compression</a></h2>
<p>Lossless compression is a method that lets us restore the original data perfectly. Unlike lossy compression, which is common for images and audio, decompressed data does not differ from the original by even a single bit. Source code and build artifacts require lossless compression because their integrity must be preserved.</p>
<p>The central idea behind lossless compression is <strong>taking advantage of statistical redundancy in data</strong>. Replacing repeated patterns with shorter representations reduces the total size.</p>
<p>Among these techniques, <strong>dictionary-based compression</strong> is a widely used family of lossless algorithms. Here, a dictionary is a mechanism that lets the compressor point back to data it has already seen. Whether that mechanism is the data already passed through or a separately built list is what separates LZ77 from LZ78. <strong>LZ77</strong>, first proposed by Jacob Ziv and Abraham Lempel in a 1977 <a href="https://doi.org/10.1109/TIT.1977.1055714" target="_blank" rel="noopener noreferrer">paper</a> in IEEE Transactions on Information Theory, and <strong>LZ78</strong>, from the <a href="https://doi.org/10.1109/TIT.1978.1055934" target="_blank" rel="noopener noreferrer">paper the two published the following year</a>, are the ancestors of this family. The letters “LZ” come from the researchers’ surnames. The dictionary-based compression algorithms that followed, including DEFLATE, LZMA, LZ4, and Zstd, trace their roots back to these two.</p>
<p>Here is a simple example. If the word “Linux” is repeated 100 times, from the second occurrence on the compressor writes a short reference pointing to “that piece seen earlier” instead of the original text. If the reference is shorter than the original, the total size shrinks.</p>
<p>So how exactly do LZ77 and LZ78 differ?</p>
<h2 id="lz77-the-sliding-window-approach"><a class="anchor" href="#lz77-the-sliding-window-approach">LZ77: the sliding-window approach</a></h2>
<p>LZ77 does <strong>not build a separate explicit dictionary</strong>. Instead, it treats a region of the input stream itself as a dictionary. This region is called a <strong>sliding window</strong> because it slides forward by as much as has been processed.</p>
<p>The window has two regions.</p>
<ul>
<li><strong>Search buffer</strong>: data that has already been processed. It acts as the dictionary.</li>
<li><strong>Look-ahead buffer</strong>: data that has not yet been processed and is about to be compressed.</li>
</ul>
<p>The algorithm checks whether the beginning of the look-ahead buffer has appeared somewhere in the search buffer. When it finds the same pattern, it encodes the match as a <strong>(distance, length, next character)</strong> tuple. Distance tells the decoder how far back to find the start of the match, while length says how many characters the match contains. The original paper recorded the first value as a position inside the buffer, and today’s implementations record the same information as a distance.</p>
<p>For example, compressing the string <code>"banana_banana"</code> with the teaching encoder included later in this post produces five LZ77 tokens: <code>(0,0,b)</code> <code>(0,0,a)</code> <code>(0,0,n)</code> <code>(2,3,_)</code> <code>(7,5,a)</code>. The first three are characters seen for the first time, so they carry only a next character and no match. The second <code>"banana"</code> is handled by the single last token <code>(7,5,a)</code>. It means “go back seven characters, copy five, and append <code>a</code>.” It does not copy all six characters because this encoder always leaves the last character of the input in the next-character slot.</p>
<p>In <code>(2,3,_)</code>, the length 3 is longer than the distance 2. With <code>ban</code> already written, the decoder starts copying from the <code>a</code> two characters back, and when it copies the third character it reads the <code>a</code> it has just written. That yields <code>ana</code>, and then <code>_</code> is appended. Followed one character at a time, it looks like this.</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Character read</th>
<th>Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>Start</td>
<td>none</td>
<td><code>ban</code></td>
</tr>
<tr>
<td>Copy 1</td>
<td>2nd character, <code>a</code></td>
<td><code>bana</code></td>
</tr>
<tr>
<td>Copy 2</td>
<td>3rd character, <code>n</code></td>
<td><code>banan</code></td>
</tr>
<tr>
<td>Copy 3</td>
<td>4th character, <code>a</code> (written in copy 1)</td>
<td><code>banana</code></td>
</tr>
<tr>
<td>Next character</td>
<td><code>_</code> from the token</td>
<td><code>banana_</code></td>
</tr>
</tbody>
</table>
<p><a href="https://www.rfc-editor.org/rfc/rfc1951" target="_blank" rel="noopener noreferrer">RFC 1951</a> specifies the same behavior: if the last two bytes are X and Y, <code>&#x3C;length = 5, distance = 2></code> appends X,Y,X,Y,X. This works because the dictionary is the data that has just been restored.</p>
<p>In this approach, <strong>the dictionary is not stored or transmitted separately.</strong> The decoder reconstructs the search buffer itself while decompressing, so the dictionary is implicitly embedded in the data itself. Because references point to earlier data, decompression basically proceeds in order from the beginning. References, however, only reach as far back as the window. RFC 1951 limits DEFLATE references to at most 32K bytes back. zlib’s <code>Z_FULL_FLUSH</code> resets the compression state so that decompression can restart from that point if earlier compressed data has been damaged or random access is needed. <a href="https://github.com/madler/zlib/blob/v1.3.1/zlib.h" target="_blank" rel="noopener noreferrer">zlib.h</a> warns that using it too often can seriously degrade compression.</p>
<p>Window size has a direct trade-off with compression ratio. A larger window can refer to patterns farther away and therefore compress better, but it uses more memory, and with more candidates to check, match searching usually gets slower too.</p>
<h2 id="lz78-an-explicit-dictionary"><a class="anchor" href="#lz78-an-explicit-dictionary">LZ78: an explicit dictionary</a></h2>
<p>Unlike LZ77, LZ78 <strong>constructs an explicit dictionary</strong> as it compresses the input. There is no sliding window. Previously observed patterns are stored as indexed dictionary entries, and later occurrences are replaced by their indexes.</p>
<p>LZ78 outputs tags in the form <strong>(dictionary index, next character)</strong>. The encoder finds the longest matching dictionary entry, outputs its index together with the next character that breaks the match, and then adds <strong>“the matched entry plus the new character”</strong> to the dictionary. The dictionary grows incrementally as the input is processed.</p>
<p>Compressing the same <code>"banana_banana"</code> with LZ78 produces eight tokens and builds up seven dictionary entries. The tokens are <code>(0,b)</code> <code>(0,a)</code> <code>(0,n)</code> <code>(2,n)</code> <code>(2,_)</code> <code>(1,a)</code> <code>(3,a)</code> <code>(7,)</code>, and the dictionary is <code>1:b</code> <code>2:a</code> <code>3:n</code> <code>4:an</code> <code>5:a_</code> <code>6:ba</code> <code>7:na</code>. The second <code>"banana"</code> is split into three tokens that produce <code>ba</code>, <code>na</code>, and <code>na</code>. This is the part LZ77 finished with one token. An LZ78 dictionary entry grows by only one character at a time, so a long entry appears only after the same word has been seen several times. The last token <code>(7,)</code> is the case where the input ends and there is no next character. This encoder does not pack tokens into bits, so token counts cannot be used to compare compression ratios.</p>
<p>You can reproduce both token streams with the code below. I saved it to a file and ran it with <code>node lz.mjs</code> on macOS 26.6.2 and Node v24.16.0 on 2026-10-08.</p>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="js" data-theme="github-dark github-light"><code data-language="js" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// LZ77: (거리, 길이, 다음 문자). 가장 긴 일치를 고르고, 겹친 복사를 허용한다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">s</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">win</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 4096</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> out</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { d: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, l: </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> Math.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">max</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> win); j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i; j</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span></span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">      // 입력의 마지막 글자는 매치에 넣지 않고 다음 문자로 남긴다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      while</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> &#x26;&#x26;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[j </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l]) l</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">></span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l) best </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { d: i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">-</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> j, l }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([best.d, best.l, s[i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l]])</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> best.l </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> out</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// LZ78: (사전 인덱스, 다음 문자). 인덱스 0 은 빈 문자열이다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">s</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> new</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> Map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> out</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> []</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; i</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> c</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s[i]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">has</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c)) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      if</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (i </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) { w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c; </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">continue</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">      out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c), </span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 입력이 끝나 다음 문자가 없다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">      break</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">([w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">?</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">get</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">:</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, c])</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">set</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c, dict.size </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    w </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> { out, dict: [</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">...</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">keys</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">()] }</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 두 디코더 모두 토큰만 받는다. 사전은 전달받지 않는다</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unlz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">tokens</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">d</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">l</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tokens) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> k </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 0</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">; k </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">&#x3C;</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> l; k</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">++</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o[o.</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">length</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> -</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> d] </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 방금 쓴 글자도 다시 읽는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">function</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> unlz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">tokens</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> dict</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">''</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  let</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> ''</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  for</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> (</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> [</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">of</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> tokens) {</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">    const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> entry</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> dict[i] </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> c</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    o </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">+=</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> entry</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">    dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">push</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(entry) </span><span style="--shiki-dark:#6A737D;--shiki-light:#6A737D">// 인코더와 같은 순서로 사전을 다시 쌓는다</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">  }</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">  return</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> o</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">}</span></span>
<span data-line=""> </span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> s</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> 'banana_banana'</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> a</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(s)</span></span>
<span data-line=""><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">const</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> b</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> =</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1"> lz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'LZ77'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, a.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(([</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">d</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">l</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `(${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">d</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">l</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">c</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">})`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">unlz77</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(a) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'LZ78'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, b.out.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(([</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">i</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">c</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">]) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `(${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">i</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">},${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">c</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">})`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">), </span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">unlz78</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(b.out) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">===</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E"> s)</span></span>
<span data-line=""><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">console.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">log</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">'사전'</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, b.dict.</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">map</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">((</span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">e</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">, </span><span style="--shiki-dark:#FFAB70;--shiki-light:#E36209">k</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">) </span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49">=></span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62"> `${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">k</span><span style="--shiki-dark:#F97583;--shiki-light:#D73A49"> +</span><span style="--shiki-dark:#79B8FF;--shiki-light:#005CC5"> 1</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}:${</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">e</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">}`</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">).</span><span style="--shiki-dark:#B392F0;--shiki-light:#6F42C1">join</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">(</span><span style="--shiki-dark:#9ECBFF;--shiki-light:#032F62">' '</span><span style="--shiki-dark:#E1E4E8;--shiki-light:#24292E">))</span></span></code></pre></figure>
<figure data-rehype-pretty-code-figure=""><pre tabindex="0" data-language="text" data-theme="github-dark github-light"><code data-language="text" data-theme="github-dark github-light" style="display: grid;"><span data-line=""><span>LZ77 (0,0,b) (0,0,a) (0,0,n) (2,3,_) (7,5,a) true</span></span>
<span data-line=""><span>LZ78 (0,b) (0,a) (0,n) (2,n) (2,_) (1,a) (3,a) (7,) true</span></span>
<span data-line=""><span>사전 1:b 2:a 3:n 4:an 5:a_ 6:ba 7:na</span></span></code></pre></figure>
<h2 id="where-the-two-diverge"><a class="anchor" href="#where-the-two-diverge">Where the two diverge</a></h2>
<p>Both decoders in the code receive only tokens. That means <strong>LZ78 does not transmit its dictionary either</strong>. The decoder reads the tokens and adds entries in the same order as the encoder, so the same dictionary is rebuilt. Not transmitting the dictionary is something the two algorithms share. Where they part ways is <strong>how they forget old content</strong>. LZ77’s window slides forward and forgets old data automatically. An LZ78 dictionary keeps growing if left alone. The original paper cleared the whole dictionary each time a block of fixed length ended, and real implementations cap the dictionary size and, once it is full, freeze it, clear it, or reuse some of its entries.</p>
<p>The best-known variation of LZ78 is <strong>LZW</strong> (Lempel-Ziv-Welch). Terry Welch published the improvement in 1984, and it was used by the GIF image format and the Unix <code>compress</code> utility with its <code>.Z</code> extension. Both implementations put a limit on the dictionary. The <a href="https://www.w3.org/Graphics/GIF/spec-gif89a.txt" target="_blank" rel="noopener noreferrer">GIF89a specification</a> caps codes at 12 bits (a maximum value of 4095) and provides a separate Clear code that returns the dictionary to its initial state. According to the <a href="https://github.com/vapier/ncompress/blob/v5.0/compress.1" target="_blank" rel="noopener noreferrer">ncompress man page</a>, <code>compress</code> keeps watching the compression ratio once the code length reaches the <code>-b</code> limit (16 bits by default) and, if the ratio drops, discards the dictionary and rebuilds it from scratch.</p>
<p>Each paper showed asymptotic optimality under its own model. The 1977 paper showed that the compression ratio of LZ77 “uniformly approaches the lower bounds” attainable by codes designed with full knowledge of the source, and the 1978 paper showed, for individual sequences, that LZ78’s incremental parsing is asymptotically optimal. Because the models differ, these results alone cannot be used to compare the two.</p>
<h2 id="descendants-of-lz77"><a class="anchor" href="#descendants-of-lz77">Descendants of LZ77</a></h2>
<p>So which side do modern compression algorithms come from? Most mainstream compression algorithms we use today are <strong>descendants of LZ77</strong>.</p>
<p><strong>LZSS</strong>, which grew out of the 1982 paper by Storer and Szymanski, is a variant of LZ77. When a match is so short that a pointer would be longer than the original, it emits a literal (the original character) instead of the pointer.</p>
<p><strong>DEFLATE</strong> was designed by Phil Katz for PKZIP 2, and its specification was written up as RFC 1951 in 1996. RFC 1951 describes DEFLATE as a combination of LZ77 and <strong>Huffman coding</strong>, an entropy-coding technique that gives shorter bit sequences to more frequent symbols. Its output is a mix of literals and (length, distance) pairs, and it merges literals and match lengths into a single alphabet (0 to 285) distinguished by one Huffman code. The compression method most ZIP programs use by default, GZIP, and PNG all use DEFLATE. In other words, most of the <code>.zip</code>, <code>.gz</code>, and <code>.png</code> files we handle every day are direct descendants of LZ77.</p>
<p>No single reason explains why the LZ77 side became mainstream, but one point the specifications themselves put forward is patents. RFC 1951 notes that many variations of LZ77 are patented, and states that the DEFLATE format can be implemented readily in a manner not covered by patents. The <a href="https://www.w3.org/TR/png-3/" target="_blank" rel="noopener noreferrer">PNG specification</a> introduces PNG at its very start as a patent-free replacement for GIF. GIF is the format that uses the LZW described earlier.</p>
<p>Later algorithms such as <strong>LZMA</strong> (used by 7-Zip and XZ), <strong>LZ4</strong>, and <strong>Zstd</strong> also started from LZ77’s sliding-window idea. LZMA and Zstd developed match searching and entropy coding together, while LZ4 dropped entropy coding entirely and chose speed with a byte-oriented format. The LZ78 family lives on as LZW inside formats such as GIF.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion">Conclusion</a></h2>
<p>LZ77 and LZ78 started from the same idea of replacing repeated patterns with short references. Neither transmits its dictionary, and in both the decoder rebuilds it, but they diverged on whether a reference points to a position in the data already seen or to the number of an entry in a separately built dictionary, and they also forget old content differently. The mainstream compressors that run from DEFLATE through LZMA, LZ4, and Zstd descend from LZ77, while the LZ78 side lives on as LZW in formats such as GIF.</p>
<p>How this lineage plays out in an actual format choice, comparing the speed and compression ratio of ZIP, GZIP, ZSTD, and XZ and deciding what to use for build artifacts, is covered in <a href="/240706" target="_blank" rel="noopener noreferrer">Understanding Compression Algorithms</a>.</p>
<p>The next time you extract a <code>.zip</code> or <code>.gz</code> file, I hope you will remember that instructions like “go back this many characters and copy this many” are being exchanged inside it.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://doi.org/10.1145/322344.322346" target="_blank" rel="noopener noreferrer">Storer, Szymanski, Data compression via textual substitution (1982)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#8b5cf6"></span><a href="https://doi.org/10.1109/MC.1984.1659158" target="_blank" rel="noopener noreferrer">Welch, A Technique for High-Performance Data Compression (1984)</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner"><span class="ref-dot" style="background:#3b82f6"></span><a href="https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT" target="_blank" rel="noopener noreferrer">PKWARE, APPNOTE.TXT: .ZIP File Format Specification</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>curiosities</category>
            <category>software</category>
        </item>
    </channel>
</rss>