<?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/es</link>
        <description>프론트엔드 개발자 이지훈(후니)의 기술 블로그. React, TypeScript, Next.js 로 만들면서 겪은 설계 판단과 문제 해결 과정, 성능과 관측 실측, 학습 노트를 남깁니다.</description>
        <lastBuildDate>Thu, 15 Oct 2026 19:38:32 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>es</language>
        <copyright>All rights reserved 2026, 이지훈</copyright>
        <item>
            <title><![CDATA[Las layers de una app React]]></title>
            <link>https://hooninedev.com/es/261001</link>
            <guid isPermaLink="false">https://hooninedev.com/es/261001</guid>
            <pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de cómo dividir el código de una app React en pantalla, reglas de negocio y acceso a datos. Está escrito para quien tiene un solo component con fetch, cálculos y render ...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de cómo dividir el código de una app React en pantalla, reglas de negocio y acceso a datos. Está escrito para quien tiene un solo component con fetch, cálculos y render juntos, y tiene que leerlo entero cada vez que cambia algo. Si llegas al final, sabrás qué señales mirar y en qué orden extraer hooks, pure components, domain objects, Strategy y un network client, y dónde se rompe esa estructura cuando la copias tal cual.</p>
<p>El esqueleto es <a href="https://martinfowler.com/articles/modularizing-react-apps.html" target="_blank" rel="noopener noreferrer">Modularizing React Applications with Established UI Patterns</a>, la serie que Juntao Qiu, de Thoughtworks, publicó en martinfowler.com en febrero de 2023. No traduzco el original tal cual, sino que lo vuelvo a contar con mis palabras, y todas las figuras del artículo vienen de ese texto. Encima añado los resultados de descargar el <a href="https://github.com/abruzzi/payment-round-up-refactoring" target="_blank" rel="noopener noreferrer">repositorio de ejemplo</a> del autor y ejecutar la comprobación de tipos y los tests, además de otros materiales que tratan el mismo problema.</p>
<h2 id="react-es-una-view-library"><a class="anchor" href="#react-es-una-view-library">React es una view library</a></h2>
<p>Cuando uno aprende React, la idea más atractiva es que la UI es una función pura que convierte datos en DOM. Hasta cierto punto es cierto. Pero en el momento en que envías una petición al servidor o navegas a otra página, el component deja de ser puro. Si a eso se le suman state global y state local enredados, el código se complica enseguida.</p>
<p>La respuesta del autor es cambiar de perspectiva. No existe un tipo de software aparte llamado "aplicación React". React es una library para dibujar UI, y dónde poner los cálculos o las reglas de negocio nunca fue asunto suyo. Lo que hay encima es una app de JavaScript normal y corriente. Entonces se puede usar tal cual el diseño que se viene usando desde la época de las GUI de escritorio, en particular la división del código en tres layers: presentation, domain y data.</p>
<p>Una app frontend real tiene, además de la view, router, local storage, varios niveles de caché, peticiones de red, integración con servicios externos e inicio de sesión, seguridad, logging y ajustes de rendimiento. Si todo eso se mete en components y hooks, en un mismo archivo la línea que pide el estado del pedido va seguida de una línea que recorta los espacios iniciales de un string, y después viene una línea que navega a otra pantalla. Quien lee tiene que ir y volver cambiando de nivel de abstracción todo el tiempo.</p>
<p>Las razones para dividir se resumen en dos. La pantalla cambia más a menudo que las reglas de negocio. Y si separas ambas, solo tienes que pensar en una cosa a la vez. La segunda razón es la que Martin Fowler da en <a href="https://martinfowler.com/bliki/PresentationDomainDataLayering.html" target="_blank" rel="noopener noreferrer">PresentationDomainDataLayering</a> como la mayor ventaja de esta separación: como se puede pensar en los tres temas de forma relativamente independiente, se reduce el alcance al que hay que prestar atención.</p>
<h2 id="cinco-etapas-en-el-crecimiento-de-una-app"><a class="anchor" href="#cinco-etapas-en-el-crecimiento-de-una-app">Cinco etapas en el crecimiento de una app</a></h2>
<p>Antes de entrar en la refactorización, el autor muestra con cinco figuras cómo cambia la estructura a medida que crece la app. Lo importante es qué provoca el paso a la etapa siguiente. No es un momento fijo, sino que la incomodidad que aparece en una etapa llama a la siguiente. Los colores significan lo mismo en las cinco figuras. Verde claro es un container component con state, verde oscuro es un presentational component que solo dibuja, morado es un hook, azul es un domain object y naranja es infraestructura como la red.</p>
<p>Al principio, un solo component tiene la petición de red, el state, los cálculos y el render. En una app pequeña o en un proyecto de un solo uso esto basta, y como el código se parece al HTML, hasta es fácil de leer. Cuando el código que recorre una lista para crear elementos se mezcla con el que configura components externos y empieza a costar tiempo leer qué está pasando, se pasa a la etapa siguiente.</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="Un solo component tiene la petición de red, la gestión del state, la domain logic y el render, y llama directamente a la API" style="max-width:min(720px, 100%);height:auto;" width="1600" height="661" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>Cuando la pantalla crece, se dividen los components siguiendo la forma del HTML resultante. Se separan components que solo dibujan, pero en el component superior siguen quedando la petición de red, el código que transforma la respuesta a la forma que necesita la pantalla y el código que reúne los datos que se envían al servidor. Ninguno de los tres es UI y, sin embargo, están dentro del 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="Se separaron los components que solo dibujan, pero la petición de red y la domain logic siguen en el component superior" style="max-width:min(720px, 100%);height:auto;" width="1600" height="984" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Después se extraen el state y sus cambios a un custom hook. Entonces queda dentro del hook un cálculo puro que no es ni side effect ni 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="La petición de red y la domain logic se movieron al hook, pero dentro del hook siguen mezcladas" style="max-width:min(720px, 100%);height:auto;" width="1600" height="872" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Si ese cálculo se saca a un objeto que no tiene nada que ver con React, aparece un domain object. Aquí van la conversión de formatos de datos, las comprobaciones de null y los valores por defecto. Cuando los objetos se multiplican, empiezan a hacer falta la herencia o el polimorfismo.</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="En el hook solo queda el state, la domain logic se va a un objeto Domain y la petición de red a un Fetcher" style="max-width:min(720px, 100%);height:auto;" width="1600" height="790" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Por último, cuando aparecen objetos que no pertenecen a la UI y a los que tampoco les importa si los datos vienen del servidor, del local storage o de una caché, se agrupan aparte en una 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="La franja central es la model layer, con los components y hooks arriba y la infraestructura como Fetcher y Adaptor abajo" style="max-width:min(720px, 100%);height:auto;" width="1600" height="917" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Viendo solo las figuras, resulta abstracto. Recorramos estas cinco etapas una vez con código real. El ejemplo que eligió el autor es la pantalla de pago de un pedido en línea.</p>
<h2 id="partir-de-una-sola-pantalla-de-pago"><a class="anchor" href="#partir-de-una-sola-pantalla-de-pago">Partir de una sola pantalla de pago</a></h2>
<p>Los métodos de pago vienen de la configuración del servidor y cambian según el país. Si el servidor devuelve al menos uno, se añade al final el pago en efectivo y se deja seleccionado por defecto; si no devuelve ninguno, no se muestra nada.</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="Pantalla con radios de apple, google y pago en efectivo en la zona de pago bajo el detalle del pedido, con el pago en efectivo seleccionado" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>El código inicial tiene la forma que se ve a menudo en los tutoriales. Hace fetch dentro de <code>useEffect</code>, transforma la respuesta a la forma que necesita la pantalla, añade el pago en efectivo y dibuja la lista de radios. Lo de abajo es mi versión resumida.</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>A este tamaño no es un problema. Pero para modificar este component hay que entender cuatro cosas a la vez: cómo se inicia la petición de red, cómo se transforman los datos del servidor a la forma que conoce la pantalla, cómo se dibuja un método de pago y cómo se dibuja toda la zona de pago. Cuando el código crece, quien lo lee tiene que ir y venir entre esas cuatro sin parar.</p>
<h2 id="hooks-y-pure-components"><a class="anchor" href="#hooks-y-pure-components">Hooks y pure components</a></h2>
<p>Lo primero que se extrae es el state y el fetch. La señal para decidir es simple. <strong>Cuando el código que no tiene que ver con el render supera la mitad del cuerpo del component</strong>, se saca a un hook. <code>usePaymentMethods</code> se encarga del fetch y la transformación, y el component solo dibuja el array que le da el hook.</p>
<p>Lo segundo es el JSX que dibuja la lista. <strong>Cuando ves un bloque que se puede dibujar recibiendo solo props</strong>, lo separas en un component. <code>PaymentMethods</code> es un pure component que recibe un único array de métodos de pago, así que es fácil de testear y reutilizar. Aunque más adelante se le añada un callback <code>onSelect</code> para avisar de la selección, sigue siendo pure porque no toca el state de fuera.</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>El autor llama a ambas operaciones <a href="https://refactoring.com/catalog/extractFunction.html" target="_blank" rel="noopener noreferrer">Extract Function</a>, del catálogo de refactorizaciones, porque en React un component también es una función. Lo importante es que no se introdujo ningún concepto nuevo: solo se dividió una función en dos.</p>
<p>Con esto parece que ya está, pero todavía quedan dos sitios. Dentro de la view está la decisión "si es efectivo, se selecciona por defecto", y dentro del hook hay una función anónima que transforma la respuesta a la forma de la pantalla. El autor llama logic leak a estas reglas de negocio que se escapan hacia la view y el hook.</p>
<h2 id="reglas-reunidas-en-un-domain-object"><a class="anchor" href="#reglas-reunidas-en-un-domain-object">Reglas reunidas en un domain object</a></h2>
<p>La tercera señal es <strong>cuando las decisiones sobre los mismos datos están repartidas entre la view y el hook</strong>. Las decisiones y transformaciones que se escaparon se reúnen en una sola class, <code>PaymentMethod</code>. Como conté en <a href="/260418" target="_blank" rel="noopener noreferrer">el modelo de dominio</a> de este blog, se trata de poner las reglas junto a los datos.</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>Ahora el radio de la view le pregunta al objeto con <code>defaultChecked={method.isDefaultMethod}</code>. El pago en efectivo por defecto también se expresa como una instancia más de la misma class. Como lo que da el servidor y lo que añade la app tienen la misma forma, la view no necesita distinguirlos.</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 llama a usePaymentMethods, el hook crea PaymentMethod y PaymentMethods solo dibuja" style="max-width:min(720px, 100%);height:auto;" width="1600" height="662" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>El autor señala como ventaja de esta estructura que los tests se vuelven más fáciles. El original se queda en esa frase, pero es más rápido ver con código cuánto más fáciles. Lo de abajo es un test que escribí yo. No hay React, ni renderer, ni mock de fetch.</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>Para comprobar la misma regla en el código inicial había que hacer render del component, interceptar el fetch y esperar a que se dibujaran los radios. En cuanto la regla sale a un objeto, el test se convierte en una línea con una llamada a función.</p>
<p>Otra ventaja es que, cuando llega un requisito nuevo, ya está decidido adónde va. En este punto la estructura de archivos es esta.</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>Si la estructura aguanta de verdad solo se sabe cuando llega un requisito nuevo. Aquí el autor añade una funcionalidad más.</p>
<h2 id="la-función-de-donación-y-el-hook-como-state-machine"><a class="anchor" href="#la-función-de-donación-y-el-hook-como-state-machine">La función de donación y el hook como state machine</a></h2>
<p>El nuevo requisito es redondear hacia arriba el importe del pedido y donar la diferencia a una organización benéfica. Si el pedido es de 19,80 dólares, se pregunta si se quiere donar 0,20 dólares y, si se acepta, el botón muestra 20 dólares.</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="Pantalla de pago con una casilla de donación de 0,2 dólares añadida debajo de los métodos de pago" style="max-width:min(600px, 100%);height:auto;" width="1600" height="1130" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>El autor, a propósito, mete al principio el state de aceptación y el cálculo dentro del component de pago. Al entrar de golpe el marcado de la casilla, la bifurcación del texto y el cálculo del total, el component vuelve a pesar. El orden para ordenarlo es el mismo que vimos antes. El state y el cálculo van al hook <code>useRoundUp</code>, el armado del texto a una función helper y el marcado de la casilla al component <code>DonationCheckbox</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:#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> es una parte cuyo motivo el original no explica. Lo ejecuté en Node 24 y <code>20 - 19.8</code> dio <code>0.1999999999999993</code>. Es un mecanismo para quitar el error de coma flotante.</p>
<p>El autor ve este tipo de hook como "una state machine detrás de la view". Cuando llega un evento desde la UI, crea un state nuevo, y el state nuevo vuelve a provocar un render. Los hooks se crearon en origen para que varios components compartieran logic, pero la postura del autor es que, aunque solo se usen en un sitio, vale la pena extraerlos porque dejan al component concentrado en el render. Añado una cosa: como señala la documentación oficial de React en <a href="https://react.dev/learn/reusing-logic-with-custom-hooks" target="_blank" rel="noopener noreferrer">Reusing Logic with Custom Hooks</a>, lo que comparte un custom hook es la logic que maneja el state, no el state en sí. Esta state machine se crea por separado, una por cada component que llama al hook.</p>
<p>Cuando se termina de ordenar, el component de pago llama a dos hooks y coloca dos components hijos y un botón. Se añadió una funcionalidad, pero el papel del component de pago no cambió.</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="Debajo de Payment están los dos hooks useRoundUp y usePaymentMethods y los dos components hijos PaymentMethods y DonationCheckbox" style="max-width:min(720px, 100%);height:auto;" width="1600" height="775" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h2 id="reglas-por-país-y-shotgun-surgery"><a class="anchor" href="#reglas-por-país-y-shotgun-surgery">Reglas por país y shotgun surgery</a></h2>
<p>El siguiente requisito es que la unidad de redondeo cambie según el país. En Japón es de 100 yenes y en Dinamarca de 10 coronas. El autor, diciendo que "parece un arreglo fácil", muestra primero a propósito el camino de recibir el código de país como prop y meter bifurcaciones.</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>La misma comprobación <code>countryCode === "JP"</code> entró en tres sitios, uno en el hook, otro en el helper y otro en el botón. Al añadir Dinamarca hay que volver a tocar los tres, y cuando el operador ternario ya no da abasto aparece una tabla que busca el símbolo de moneda por código de país. Esa tabla también acaba referenciándose por separado en varios sitios. Este olor, en el que un solo cambio obliga a modificar varios módulos a la vez, se llama 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="Barras de color que representan las reglas por país repartidas entre varios components, hooks y domain objects" style="max-width:min(720px, 100%);height:auto;" width="1600" height="902" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Lo que distingue esta etapa de las tres anteriores es que la señal para decidir no viene del código, sino de las <strong>peticiones de cambio</strong>. Si solo miras el código actual, tres bifurcaciones no son difíciles de leer. El problema se ve cuando cuentas cuántos sitios hay que tocar para añadir un país más. Entonces, ¿adónde hay que reunir las bifurcaciones dispersas?</p>
<h2 id="las-diferencias-por-país-reunidas-con-strategy"><a class="anchor" href="#las-diferencias-por-país-reunidas-con-strategy">Las diferencias por país reunidas con Strategy</a></h2>
<p>La respuesta del autor es convertir en objeto la propia diferencia entre países. Primero, con <a href="https://refactoring.com/catalog/extractClass.html" target="_blank" rel="noopener noreferrer">Extract Class</a> y <a href="https://refactoring.com/catalog/replaceConditionalWithPolymorphism.html" target="_blank" rel="noopener noreferrer">Replace Conditional with Polymorphism</a>, crea una interface y una implementación por país.</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>Una vez hechas, lo único que cambia entre implementaciones es el símbolo de moneda y la función de redondeo; el resto, como <code>getTip</code>, es idéntico. Así que el autor fusiona las implementaciones en una sola class con <a href="https://refactoring.com/catalog/inlineClass.html" target="_blank" rel="noopener noreferrer">Inline Class</a> y recibe solo lo que cambia por el constructor. Se puede recibir el algoritmo de redondeo como función, sin crear una subclass por país, porque en JavaScript las funciones son valores. Lo de abajo es mi versión resumida del resultado.</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>Ahora el hook y el component no saben nada del país. Reciben este único objeto y se limitan a usar <code>strategy.getRoundUpAmount(amount)</code> y <code>strategy.currencySign</code>. Para añadir un país basta con crear un objeto más, y no hay que tocar ningún archivo.</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="El component mira solo a PaymentStrategy, y las tres implementaciones por país se reúnen detrás" style="max-width:min(720px, 100%);height:auto;" width="1600" height="847" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Pasar por una interface y luego volver a fusionar puede parecer un rodeo innecesario. Yo creo que es la parte del original de la que más se puede aprender. El polimorfismo no era el objetivo, sino una herramienta para hacer visibles lo común y lo diferente, y en cuanto se vio que la diferencia se reducía a una función, se quitó la jerarquía de classes. Más difícil que introducir un patrón es decidir cuándo volver a quitarlo.</p>
<h2 id="sacar-el-fetch-del-hook"><a class="anchor" href="#sacar-el-fetch-del-hook">Sacar el fetch del hook</a></h2>
<p>Lo último que se extrae es el fetch y la transformación dentro de <code>usePaymentMethods</code>. Un hook es un concepto de React, así que se queda del lado de la view. Si le añades error handling y retry, el hook se hincha enseguida, y si te mudas a otra view library, el hook no sirve. En cambio, una función normal se puede usar en cualquier sitio.</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>El autor escribe que esta función funciona como una Anti-Corruption Layer o un Gateway. Quiere decir que, aunque cambie la estructura de la respuesta del servidor, lo que hay que tocar se queda en esta única función. Pero los dos nombres no son lo mismo. El <a href="https://martinfowler.com/articles/gateway-pattern.html" target="_blank" rel="noopener noreferrer">Gateway</a> de Fowler es un objeto que envuelve en un solo lugar el acceso a un sistema externo, mientras que Anti-Corruption Layer es un término que viene del DDD de Eric Evans y designa la frontera que traduce un modelo externo con otro sistema de significados para que no contamine el tuyo. Como la respuesta del servidor en este ejemplo es solo <code>{ name }</code> y casi no hay diferencias de significado que traducir, creo que Gateway es el nombre más preciso. Ese mismo lugar se convierte en una Anti-Corruption Layer cuando el servidor es un sistema legacy de otro equipo y el propio significado de "método de pago" es distinto.</p>
<p>La estructura final es esta. El render está en los components, el state en los hooks, las reglas en los domain objects y las peticiones de red en funciones.</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="Payment y sus components hijos, los hooks useRoundUp y usePaymentMethods, los domain objects PaymentStrategy y PaymentMethod y el Fetcher, cada uno en su casilla en la estructura final" style="max-width:min(720px, 100%);height:auto;" width="1600" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>El autor señala cinco ventajas de dividir así. Es más fácil encontrar dónde está un defecto. Es más fácil reutilizar y combinar código. Es más fácil de leer. Añadir funcionalidades no hace tambalear el conjunto. Y como la domain logic no conoce la view, se puede cambiar solo la view layer. Sobre la última, el propio autor advierte que en la mayoría de los proyectos es algo muy raro. Vale la pena recordar también que ninguna de las cinco viene con mediciones.</p>
<p>Hasta aquí llega el argumento del original. Pero cuando ejecutas este código de verdad, aparecen cosas que en el artículo no se veían.</p>
<h2 id="lo-que-se-ve-al-ejecutar-el-ejemplo"><a class="anchor" href="#lo-que-se-ve-al-ejecutar-el-ejemplo">Lo que se ve al ejecutar el ejemplo</a></h2>
<p>Descargué el repositorio de ejemplo del autor (16 commits, último commit <code>de186c5</code>) y ejecuté <code>tsc --noEmit</code> y los 7 tests. Ambos pasaron, y los helpers cuya definición falta en el original estaban en <code>src/utils.ts</code> del repositorio. Pero al mirar la estructura de cerca encontré tres cosas que conviene saber antes de copiarla.</p>
<h3 id="una-strategy-que-se-crea-de-nuevo-en-cada-render"><a class="anchor" href="#una-strategy-que-se-crea-de-nuevo-en-cada-render">Una Strategy que se crea de nuevo en cada render</a></h3>
<p>El component de pago recibe la Strategy como prop y, si no se le pasa, crea el valor por defecto con <code>new</code>. Y <code>useRoundUp</code> mete esa Strategy en la dependency de <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>En JavaScript, los default parameters <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Default_parameters" target="_blank" rel="noopener noreferrer">se evalúan en cada llamada</a>. Y <code>useMemo</code> compara las dependencies con <code>Object.is</code>. Cuando se juntan las dos cosas, se crea una instancia nueva en cada render y el memo recalcula cada vez. Hice render de la misma estructura cuatro veces en React 18: con el valor por defecto el cálculo se ejecutó 4 veces, y pasando la misma instancia, 1 vez. El <code>App.tsx</code> del repositorio no pasa Strategy (<code>&#x3C;Payment amount={19.9} /></code>), así que la ruta de ejecución de la app es exactamente la del valor por defecto.</p>
<p>El cálculo de este ejemplo son unas pocas sumas, así que no tiene coste. El problema es la estructura. Ni el artículo ni el repositorio definen quién crea la Strategy ni dónde se pasa, y solo los tests crean instancias de Japón y Dinamarca. Yo dejaría las instancias como constantes a nivel de module y las sacaría por código de país para pasarlas.</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="el-fetch-del-repositorio-no-tiene-dependency-array"><a class="anchor" href="#el-fetch-del-repositorio-no-tiene-dependency-array">El fetch del repositorio no tiene dependency array</a></h3>
<p>El <code>useEffect</code> del código del original tiene <code>[]</code>, pero el <code>usePaymentMethods</code> del repositorio no tiene dependency array desde el primer commit hasta el último. Un effect sin array se vuelve a ejecutar en cada render. Cuando llega la respuesta, cambia el state con un array nuevo, eso provoca un render y el effect vuelve a hacer fetch. Como <code>convertPaymentMethods</code> devuelve un array nuevo cada vez, no se detiene.</p>
<p>Cambiando fetch por un mock que responde de inmediato y contando las llamadas durante 300 ms en Jest, en cuatro mediciones salieron 152, 225, 231 y 233. Con <code>[]</code> es 1. Los tests del repositorio solo comprueban que el texto aparece en pantalla, así que no detectan esta repetición y pasan todos. A quien escribió el código siguiendo el original no le pasa; solo afecta a quien usa el repositorio tal cual.</p>
<h3 id="la-race-que-aparece-en-cuanto-el-código-de-país-llega-como-prop"><a class="anchor" href="#la-race-que-aparece-en-cuanto-el-código-de-país-llega-como-prop">La race que aparece en cuanto el código de país llega como prop</a></h3>
<p>El fetch effect del código del original tampoco tiene cleanup. <a href="https://react.dev/learn/synchronizing-with-effects" target="_blank" rel="noopener noreferrer">Synchronizing with Effects</a>, de la documentación oficial de React, dice que si haces fetch dentro de un effect, una respuesta anterior que llega tarde puede pisar la más reciente, así que hay que descartar las respuestas anteriores con un flag <code>ignore</code>. El original solo hace fetch una vez al hacer mount, así que de momento no hay problema. Pero en cuanto el <code>countryCode=AU</code> de la URL se recibe como prop, aparece una dependency y se abre la race. Si yo copiara esta estructura, la escribiría así desde el principio.</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>Una cosa más: el redondeo del original es <code>Math.floor(amount + 1)</code>, así que si el importe ya es entero sube 1 más. Un pedido de 20 dólares pasa a 21. El original solo pone como ejemplo 19,80 dólares y 3312 yenes, así que no se puede saber si es intencionado. Si lo llevas a un servicio real, primero hay que decidir en qué se diferencia de <code>Math.ceil</code>.</p>
<h2 id="cuándo-usar-esta-estructura"><a class="anchor" href="#cuándo-usar-esta-estructura">Cuándo usar esta estructura</a></h2>
<p>El propio autor dice que dividir no es una regla fija. Un component pequeño y cohesionado se entiende mejor en un solo archivo, y lo que hay que vigilar es que el archivo crezca hasta no poder entenderse. Una app de Todo o una app de un solo formulario puede meterlo todo en components sin problema, y aclara que hizo el ejemplo complicado a propósito para mostrar muchos patrones. Otros materiales ponen salvedades en la misma dirección.</p>
<p>En el mismo artículo sobre layering, Fowler dice que esta separación solo se use en unidades relativamente pequeñas. Tener view, model y data como carpetas de primer nivel está bien en un sistema pequeño, pero si alguna crece demasiado, hay que dividir el primer nivel por dominio y dentro de él por layer. Si copias tal cual las carpetas <code>components/</code>, <code>hooks/</code> y <code>models/</code> que vimos antes como estructura de primer nivel de una app grande, acabas con la forma que Fowler dice que hay que evitar.</p>
<p>Dan Abramov tiene una trayectoria parecida. En 2015 propuso dividir en <a href="https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0" target="_blank" rel="noopener noreferrer">presentational y container components</a>, y en 2019 añadió al principio del artículo una actualización diciendo que ya no recomendaba dividirlos así. La razón era que los Hooks hacen lo mismo sin una división arbitraria, y añadió que había visto demasiadas veces cómo se imponía de forma dogmática sin necesidad. La extracción de hooks del original se parece a lo que dice esa actualización. No hay un component aparte llamado container: <code>Payment</code>, que llama a los hooks, cumple también ese papel.</p>
<p>Sobre el fetch, la documentación oficial de React va un paso más allá. <a href="https://react.dev/learn/you-might-not-need-an-effect" target="_blank" rel="noopener noreferrer">You Might Not Need an Effect</a> dice que extraer la fetch logic a un custom hook facilita migrar después a una estrategia mejor, pero como primera opción recomienda el fetching integrado del framework o una client cache como TanStack Query. Si sacas el fetch a una función normal como en la última etapa del original, puedes pasar esa función tal cual como <code>queryFn</code> de TanStack Query, y la race y las llamadas repetidas de la sección anterior quedan a cargo de la library. Creo que esta es la ventaja más práctica, y el original la menciona en una sola frase. Cómo agrupar claves y funciones lo traté aparte en <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a>.</p>
<p>En resumen, mi juicio es este. El orden del original (hook, pure component, domain object, Strategy, network client) vale la pena seguirlo tal cual, porque en cada etapa la incomodidad de esa etapa se convierte en el motivo de la siguiente. Pero no hace falta llegar hasta el final: basta con ver de qué etapa es la incomodidad que tienes ahora y pararte ahí. Si hay mucho código que no tiene que ver con el render, hasta el hook; si la misma decisión está dispersa, hasta el domain object; si un solo cambio te obliga a tocar varios archivos, hasta Strategy. Y si usas Strategy, decide primero dónde se crean las instancias, y si dejas el fetch en un effect, escribe primero el cleanup y las dependencies. Estas dos cosas que el original omite fueron lo primero que se rompió cuando lo copié de verdad.</p>
<p>No hay una respuesta correcta, pero espero que tú también te pares a pensar de qué etapa es la incomodidad que está sufriendo tu código ahora mismo.</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, patrón Anti-corruption Layer</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>arquitectura</category>
        </item>
        <item>
            <title><![CDATA[Modelos de decisión, Jev y Kev]]></title>
            <link>https://hooninedev.com/es/260922</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260922</guid>
            <pubDate>Tue, 22 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de Jev, un modelo que TypeSafe AI presentó la semana pasada y que devuelve probabilidades en vez de texto. Está pensado para desarrolladores que quieren poner un umbral ...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de Jev, un modelo que TypeSafe AI presentó la semana pasada y que devuelve probabilidades en vez de texto. Está pensado para desarrolladores que quieren poner un umbral a la probabilidad que devuelve un modelo y procesar casos automáticamente sin revisión humana. Al terminar sabrás cómo se calcula el <code>confidence</code> de Jev, cuánto se mueve la calibración según la distribución y un procedimiento para fijar el umbral con tus propios datos.</p>
<p>El primer uso que me llamó la atención fue la automatización del navegador. Browserbase abrió un <a href="https://github.com/browserbase/stagehand/pull/2953" target="_blank" rel="noopener noreferrer">PR</a> que conecta Jev al <code>act()</code> de Stagehand. La estructura era enviar el <span class="glossary-term-source" data-glossary-key="accessibility-tree">árbol de accesibilidad</span> de la página como <code>state</code> y preguntar, con opciones cerradas, «cuál es el siguiente elemento que hay que pulsar». En 40 tareas, la mediana de <code>act()</code> bajó de 1,97 segundos a 0,46, y de 147 acciones solo 4 volvieron a un LLM. El punto donde un script de Playwright se rompe en cuanto cambia un selector quedó cubierto por una llamada que devuelve una sola probabilidad. Mientras escribo esto, el PR todavía no se ha fusionado.</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="Cómo act() de Stagehand usa Jev. Se construye una lista de candidatos a partir de la instrucción y el árbol de accesibilidad, se pregunta a Jev cuál es y si ninguno aplica, se pulsa el elemento cuando la probabilidad de «ninguno» es baja y se pasa a un LLM cuando es alta. 143 de 147 acciones terminaron con Jev" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>La línea que trazó ese PR es la pregunta de este artículo. Acepta la respuesta de Jev a partir de una <a href="https://www.browserbase.com/blog/what-is-jev" target="_blank" rel="noopener noreferrer">probabilidad de 0,7</a> y, si no la alcanza, se la pasa a un LLM. <strong>¿Puedo fiarme de la probabilidad que devuelve ese modelo y trazar con ella una línea en el código?</strong></p>
<p>Con el benchmark de otros no basta para decidirlo, así que quiero verificarlo con mis propios datos. Este blog tiene una compuerta que bloquea los extranjerismos transcritos al hangul. La regla dice que <code>피커</code> debe escribirse <code>picker</code>, pero la expresión regular solo atrapa la mitad segura, y las palabras que viven dentro de un compuesto o ya están asentadas en coreano, como <code>멀티스레드</code> o <code>콜 스택</code>, las decido yo a mano cada vez. Solo hay dos respuestas, revertir o dejar, quien sabe decide en un segundo, y el aprobado o el rechazo tiene que decidirse dentro de un script de build sin que nadie lo revise. Tiene la misma forma que la decisión de selector de Stagehand.</p>
<p>Así que el orden es este: qué devuelve Jev, de qué entrenamiento sale esa probabilidad, qué números aparecen cuando le paso mi compuerta y dónde se pueden usar esos números.</p>
<h2 id="un-modelo-que-no-genera-texto"><a class="anchor" href="#un-modelo-que-no-genera-texto">Un modelo que no genera texto</a></h2>
<p>La fecha de publicación es el 15 de septiembre de 2026. La empresa no lo llama LLM; lo sitúa en una categoría nueva: System One Model. En este artículo los llamo modelos de decisión. Su fundador, Diogo Almeida, participó en OpenAI como cuarto autor del <a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">artículo de InstructGPT</a>. Estuvo en el equipo que creó el método del que ChatGPT desciende en línea directa, y ahora está en el lado que señala los límites de ese método.</p>
<p>Jev no genera frases. Le das de antemano la forma de la respuesta, elige dentro de ella y devuelve además una probabilidad. Solo hay tres tipos de pregunta.</p>
<table>
<thead>
<tr>
<th>Tipo</th>
<th>Qué pregunta</th>
<th>Qué devuelve</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>choice</code></td>
<td>Cuál de estas opciones es</td>
<td>La elección, <code>probabilities</code>, <code>confidence</code></td>
</tr>
<tr>
<td><code>score</code></td>
<td>Qué nivel tiene</td>
<td>La puntuación, <code>legend</code>, <code>probabilities</code>, <code>confidence</code></td>
</tr>
<tr>
<td><code>noul</code></td>
<td>Si es cierto</td>
<td>Una única probabilidad entre 0 y 1</td>
</tr>
</tbody>
</table>
<p>El endpoint también es uno solo. Envías a <code>POST /v1/systemone</code> un <code>state</code> (el contenido que se evalúa), un <code>model</code> y unas <code>questions</code> (el mapa de preguntas), y llega la respuesta. Puedes meter varias preguntas sobre el mismo <code>state</code> en una sola petición, y añadir preguntas apenas alarga el tiempo de respuesta.</p>
<p>Por qué es rápido se explica con la asimetría entre <span class="glossary-term-source" data-glossary-key="prefill">prefill</span> y decode que recogí en <a href="/260610" target="_blank" rel="noopener noreferrer">cómo funcionan los tokens</a>. Un LLM es lento porque exprime la salida token a token, de forma secuencial, y Jev no tiene esa etapa de decode. Lee la entrada una vez en paralelo y lee la probabilidad directamente. Por eso no hay tarifa alguna de tokens de salida y solo se cobra la entrada, a $0.042 por millón de tokens.</p>
<p>Hay un <a href="https://archerhume.com/posts/jevs-architecture-unmasked/" target="_blank" rel="noopener noreferrer">análisis</a> de un desarrollador llamado Archer Hume que llamó unas 10,000 veces a la API y reconstruyó el comportamiento desde fuera. Con 360 tokens de entrada la mediana fue 57.5ms; con 29,835 tokens, 218ms; y con 1,500 preguntas metidas, 610ms. De todo ello, la evidencia más directa es la observación de que una respuesta con 200 opciones volvía a la misma velocidad que una de 2. Significa que escribir la respuesta no cuesta tiempo.</p>
<h2 id="decisiones-que-nadie-mira"><a class="anchor" href="#decisiones-que-nadie-mira">Decisiones que nadie mira</a></h2>
<p>La velocidad y el precio llaman la atención, pero la pregunta que Almeida lanzó en su artículo de presentación iba por otro lado: si los modelos llevan años siendo sobrehumanos conversando, ¿adónde ha ido toda la automatización?</p>
<p>Su respuesta es que se trata de dos clases de trabajo distintas. Un chatbot, un copilot y un agente de programación tienen como objetivo satisfacer a la persona que mira desde al lado. Las decisiones que se ejecutan en silencio en un servidor y que nadie mira no tienen ese objetivo. Si llamamos assistant a lo primero y automation a lo segundo, todos los modelos publicados hasta hoy se hicieron para lo primero.</p>
<p>Ya hice una distinción parecida al ordenar <a href="/260622" target="_blank" rel="noopener noreferrer">el diseño de harness</a>. Dentro de un sistema de agentes hay incontables decisiones que nadie mira. ¿Paso esta petición a una persona? ¿Puedo ejecutar este comando? Ahora mismo se las preguntamos todas a un LLM caro. La otra mitad que mi compuerta de expresiones regulares no cubre también es una decisión de ese tipo.</p>
<h2 id="el-hueco-que-dejó-rlhf"><a class="anchor" href="#el-hueco-que-dejó-rlhf">El hueco que dejó RLHF</a></h2>
<p>Pero ¿por qué hacía falta un método de entrenamiento nuevo? ¿No basta con pedirle solo sí/no a un modelo de los de siempre?</p>
<p>La pista está en la <span class="glossary-term-source" data-glossary-key="calibration">calibración</span>. Si la exactitud es qué porcentaje aciertas, la calibración es si sabes qué porcentaje vas a acertar, y la métrica que mide ese desajuste es el <span class="glossary-term-source" data-glossary-key="ece">ECE</span> (expected calibration error). Es la media de la diferencia entre la probabilidad declarada y la tasa real de acierto en cada tramo de probabilidad, ponderada por la proporción de muestras, así que 0 es la perfección. Pero hay un caso en que esta escala se desajustó tras pasar por <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span> (reinforcement learning from human feedback), que optimiza la preferencia humana. En la Figure 8 del <a href="https://arxiv.org/abs/2303.08774" target="_blank" rel="noopener noreferrer">informe técnico de GPT-4</a>, el ECE del modelo preentrenado era <strong>0.007</strong>, y el del modelo tras el post-training, <strong>0.074</strong>, más de diez veces peor. El informe no da la causa, y yo tampoco encontré una fuente primaria que la establezca. La definición y hasta dónde se conoce la causa los dejé explicados aparte en <a href="/260917" target="_blank" rel="noopener noreferrer">Calibración y exceso de confianza en los LLM</a>.</p>
<p><strong>Aun así, el ECE por sí solo no basta.</strong> Un predictor constante que estampa 0.6 en todas las entradas también tiene ECE 0 si su tasa real de acierto es del 60%. Si la probabilidad solo es honesta pero no se separa caso por caso, no hay sitio donde trazar la línea. Por eso, al margen de la calibración, hay que mirar también <strong>si la probabilidad de verdad se separa</strong>, y «la proporción que se puede automatizar dentro de un presupuesto de error», que usaré más adelante, es la métrica que reúne esas dos cosas en una sola cifra.</p>
<p>Desde el punto de vista del software, lo que importa es esta parte. Si la probabilidad es honesta y se separa, se puede trazar la línea. Sale una estructura en la que por encima de 0.95 se procesa automáticamente y por debajo se pasa a una persona.</p>
<p>TypeSafe dice haber abierto un tercer camino apuntando a ese lugar: RLHF, que optimiza la preferencia humana; RLVR (reinforcement learning with verifiable rewards), que optimiza respuestas correctas que una máquina puede puntuar; y RLCD, que según ellos optimiza decisiones calibradas.</p>
<p>Ahora bien, de RLCD lo único publicado son tres líneas de contrato de salida. A fecha de 23 de septiembre de 2026, cuando escribo este artículo, no hay artículo ni informe técnico en ninguna parte de la documentación ni del artículo de presentación de TypeSafe. Existe por separado un <a href="https://arxiv.org/abs/2307.12950" target="_blank" rel="noopener noreferrer">artículo de ICLR 2024</a> que usa la misma sigla, pero ahí significa reinforcement learning from contrastive distillation, un método distinto. Conviene no mezclarlos al buscar.</p>
<h2 id="qué-es-en-realidad-confidence"><a class="anchor" href="#qué-es-en-realidad-confidence">Qué es en realidad confidence</a></h2>
<p>Entonces, ¿es honesto el número que devuelve Jev? Antes de responder hay que ver una cosa. <strong>El número que devuelve no es uno solo.</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="En la respuesta de Jev, probabilities se produce mediante el entrenamiento y confidence es esa distribución plegada por aritmética; la afirmación de calibración recae solo sobre probabilities" style="max-width:min(720px, 100%);height:auto;" width="1560" height="840" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Las respuestas de <code>Choice</code> y <code>Score</code> traen juntos <code>probabilities</code> y <code>confidence</code>. Lo primero es una distribución de probabilidad sobre todas las opciones; lo segundo, un único número entre 0 y 1. Cuando pones un umbral en el código, la mano va primero hacia <code>confidence</code>.</p>
<p>Este valor no lo produce el modelo. El archivo <code>_utils/confidence_metrics.py</code> de <a href="https://github.com/typesafe-ai/system-one-adapter-python" target="_blank" rel="noopener noreferrer">system-one-adapter-python</a>, publicado por la organización TypeSafe, tiene 32 líneas en total, y la parte de <code>Choice</code> es así.</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>No hay llamada al modelo ni parámetros aprendidos. Entra un vector de <code>probabilities</code> y sale un número real. Escrito como fórmula es <code>c = (p_max − 1/K) / (1 − 1/K)</code>, y si hay tres opciones y la probabilidad máxima es 0.8, da 0.7.</p>
<p>Este repositorio no es el servidor Jev de producción, sino una implementación alternativa que imita la misma API con un LLM. Así que esto por sí solo no permite afirmarlo con rotundidad. Pero hay dos pruebas más que apuntan a la misma fórmula. La <a href="https://docs.typesafe.ai/confidence" target="_blank" rel="noopener noreferrer">página sobre confidence</a> de la documentación oficial de TypeSafe describe este valor como "a statistic computed from the probability distribution" y en su código de demostración incluye <code>(3 × largest probability − 1) / 2</code> como fórmula aproximada para tres opciones. Con K igual a 3 coincide con la fórmula de arriba. Y un <a href="https://github.com/SamuelSacco/jev-exploration" target="_blank" rel="noopener noreferrer">registro de auditoría de afirmaciones</a> dejó anotado que, en la API real, con una respuesta a la que se dieron solo dos opciones, ambas incorrectas, y cuya ganadora valía 0.52, el <code>confidence</code> llegó como 0.04. Con K igual a 2, <code>(0.52 − 0.5) / (1 − 0.5) = 0.04</code>. Coincide con la fórmula.</p>
<p>En el mismo repositorio, <code>Score</code> usa directamente otra fórmula: divide la distancia absoluta media respecto al nivel más frecuente entre la que daría una distribución uniforme y luego <strong>lo resta de 1</strong>. La documentación no publica la fórmula de <code>Score</code>. Es decir, bajo un mismo nombre, <code>confidence</code>, hay dos fórmulas, y si cambia el tipo el mismo número significa otra cosa. Que <code>Noul</code> no tenga <code>confidence</code> obedece a la misma razón: no hay distribución que plegar.</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="Diagrama de la escala de p_max estirada hasta la escala de confidence, llevando 1/K a 0 y 1 a 1. Con tres opciones 0,80 pasa a 0,7, todo lo que queda bajo 1/3 se descarta y el orden no cambia" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>La documentación tampoco lo esconde. Incluso indica que, si quieres usar otro cálculo, te dará el <code>probabilities</code> completo para que lo hagas a tu manera. <strong>Está escrito; el problema está en usarlo sin leerlo.</strong></p>
<p>Pero la tabla comparativa del <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev" target="_blank" rel="noopener noreferrer">artículo de presentación</a> pega ambas cosas. Dice "Calibrated: higher confidence means higher accuracy". Quien lo lee entiende que <code>confidence</code> es el valor calibrado, cuando la afirmación de calibración recae en realidad sobre <code>probabilities</code>.</p>
<p>Cuánto se abre esta distinción en cifras es algo que ya se ha medido. Una <a href="https://github.com/scienthoon/jev-ood-calibration" target="_blank" rel="noopener noreferrer">medición independiente de la calibración</a> publicada por un desarrollador llamado <code>scienthoon</code> midió por separado el ECE que sale al leer <code>confidence</code> como «probabilidad de acertar». En OpenBookQA es 0.035; en HellaSwag, 0.078; y en un conjunto sintético, 0.18. Las probabilidades originales de esos mismos conjuntos eran 0.024, 0.029 y 0.107, respectivamente. <strong>En los tres conjuntos, el lado de <code>confidence</code> sale peor.</strong></p>
<p>El porqué del empeoramiento está en la fórmula. Con K fijo, <code>c</code> es una transformación monótona creciente de <code>p_max</code>, así que conserva el orden. El poder discriminativo se mantiene y basta con reescribir el umbral. <strong>Lo que se rompe no es la información, sino la escala.</strong> Si K cambia de una pregunta a otra, aparece un problema más: también cambia el factor de estiramiento. El mismo <code>p_max</code> de 0.8 se convierte en 0.6 con dos opciones y en 0.75 con cinco. En una carga de trabajo donde se mezclan números de opciones distintos, eso se vuelve un problema en cuanto usas un único umbral.</p>
<h2 id="la-calibración-cambia-según-la-distribución"><a class="anchor" href="#la-calibración-cambia-según-la-distribución">La calibración cambia según la distribución</a></h2>
<p>Entonces, ¿se puede confiar en el lado de <code>probabilities</code>?</p>
<p>Hay publicado un registro de mediciones hechas llamando de verdad a Jev. En el repositorio de <a href="https://github.com/jaredpalmer/kev" target="_blank" rel="noopener noreferrer">Kev</a>, la implementación de reproducción de código abierto que hizo Jared Palmer, hay un directorio <code>runs/jev-*</code>, y <code>usage.json</code> deja constancia, con <code>"model": "typesafe-ai/jev"</code>, de que se trata de llamadas reales pasadas por Vercel AI Gateway. Si se suman los seis directorios <code>jev-*</code> con su <code>usage.json</code>, entre el 18 y el 20 de septiembre de 2026 se hicieron 5,364 llamadas, con 2,418,260 tokens de entrada. De los siete conjuntos de abajo, <code>scienthoon</code> se importó convirtiendo los archivos de resultados de ese desarrollador, así que no está incluido en este total.</p>
<p>Que el precio cobre realmente solo la entrada se confirma en otro sitio. El autor del benchmark de phishing lo anotó mirando el dashboard de TypeSafe. De los 4.56 millones de tokens de dos ejecuciones, 3.66 millones fueron de entrada y 900,000 de salida, y el cargo fue de $0.15, una cifra que coincidía con calcular solo la entrada.</p>
<p>Si se sacan del <code>report.json</code> de ese mismo directorio las métricas de los siete conjuntos de evaluación y se ordenan, queda así. Abajo, <strong>la confianza no es el campo <code>confidence</code>, sino el máximo de <code>probabilities</code></strong>. El exceso de confianza es <code>평균 확신도 − 정확도</code> (confianza media menos exactitud) y en total son 5,743 casos. Los valores son los del bloque <code>clean</code>, que excluye de la puntuación de exactitud las entradas «desconocido», aquellas cuya base de juicio se ha borrado.</p>
<table>
<thead>
<tr>
<th>Conjunto de evaluación</th>
<th>n</th>
<th>Exactitud</th>
<th>Confianza media</th>
<th>Exceso de confianza</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>Se leen dos cosas.</p>
<p>Primera: <strong>el ECE se mueve casi diez veces.</strong> Va de 0.011 a 0.105. Mismo corrector, mismo modelo. «Está calibrado» no es una propiedad del modelo, sino una relación entre el modelo y la distribución. Que la calibración se venga abajo cuando cambia la distribución es un resultado ya conocido. Lo nuevo aquí es que <strong>la misma forma aparece también en un modelo que presenta la calibración como objetivo de entrenamiento</strong>.</p>
<p>El 0.105 del conjunto con mayor ECE es incluso mayor que el 0.074 del GPT-4 post-RLHF que vimos antes. Ahora bien, estos dos no deben leerse como un ranking. Las tareas son distintas, y el ECE es un estimador cuyo valor se mueve según en cuántos tramos se divida y en qué tramo se acumulen las muestras, de modo que enfrentar cifras salidas de correctores distintos no constituye una comparación válida.</p>
<p>Segunda: <strong>el ECE medido se explica casi por completo con el exceso de confianza.</strong> La diferencia entre ambas columnas está, en los siete casos, entre 0.002 y 0.011. Como el ECE es la media ponderada de las brechas por tramo, no puede ser menor que el exceso de confianza global; que esa diferencia sea casi 0 significa que <strong>el signo del desajuste de cada tramo se inclina en una sola dirección</strong>. No es ruido que se cruce y se cancele entre tramos.</p>
<p>Por qué ocurre se ve leyendo la tabla en vertical. Si quitamos <code>semif</code>, seis valores de confianza media se apelotonan entre 0.851 y 0.906. En ese mismo rango, la exactitud se mueve entre 0.753 y 0.857, el doble de ancho. <strong>La confianza apenas se mueve aunque cambie la distribución; solo se mueve la exactitud. La diferencia que queda es directamente el 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="Gráfico de siete conjuntos de evaluación con llamadas reales a Jev: la confianza media se agrupa entre 0,851 y 0,906 mientras solo la exactitud se mueve de 0,753 a 0,965, y esa brecha casi coincide con el ECE" style="max-width:min(720px, 100%);height:auto;" width="1560" height="920" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Otro benchmark muestra cómo se manifiesta esta propiedad en la práctica. En una <a href="https://github.com/anisselbd/jev-phishing-bench" target="_blank" rel="noopener noreferrer">medición</a> sobre 2,000 correos de phishing con Claude Haiku 4.5 como grupo de control, la exactitud de Jev fue del 62.6% y la de Haiku del 81.3%. Pero lo que más llama la atención es el ECE. El ECE que reporta el repositorio es 0.154 para Jev y 0.097 para Haiku, y si el registro de auditoría de afirmaciones vuelve a medir el valor <code>P(phishing)</code> de Jev en 10 tramos de 0 a 1, sale 0.170. Se lea como se lea, <strong>el modelo que presenta la calibración como objetivo de entrenamiento perdió en calibración frente a un LLM hecho con RLHF.</strong> El mismo autor sacó un 91.6% solo con una regla de lista de hosts de enlaces.</p>
<p>Si se mira la misma métrica en varios conjuntos, la amplitud queda más clara. La proporción que se puede automatizar con un <span class="glossary-term-source" data-glossary-key="error-budget">presupuesto de error</span> del 5% es 0.486 en el conjunto <code>scienthoon</code>, 0.695 en <code>transfer-v9</code> y 1.000 en <code>semif</code>. El corrector de Kev anota que este valor es el máximo obtenido eligiendo el umbral dentro de la muestra, y no una garantía de error tras el despliegue. <strong>Ninguna de ellas debe citarse como si fuera una especificación del modelo.</strong></p>
<h2 id="la-compuerta-de-transcripciones"><a class="anchor" href="#la-compuerta-de-transcripciones">La compuerta de transcripciones</a></h2>
<p>Leer el benchmark de otro y medir sobre mis propios datos son cosas distintas. Así que hice pasar de verdad por el modelo la compuerta del principio.</p>
<p>Todavía no tengo una clave de API de Jev. En su lugar <strong>ejecuté en local el Kev-9B</strong> que mencioné antes. Es una implementación de reproducción que monta un <span class="glossary-term-source" data-glossary-key="lora">LoRA</span> de rank 16 y una pointer head sobre Qwen3.5-9B-Base, con licencia Apache-2.0. Las cifras de abajo no son las de Jev. Los datos de entrenamiento de Kev son tareas de decisión en inglés, y en la medición de su propio autor Kev-9B queda por detrás de Jev incluso en tareas en inglés: su proporción de automatización con un presupuesto de error del 5% va de 0.45 a 0.57, mientras que la de Jev es 0.70, y en MMLU-Pro es 0.52 frente a 0.84. Además, la propia documentación de TypeSafe declara sobre Jev que <a href="https://docs.typesafe.ai/models" target="_blank" rel="noopener noreferrer">el inglés es su lengua principal de entrenamiento y el CJK no está al mismo nivel</a>.</p>
<p>El conjunto de evaluación lo saqué de 18 artículos en coreano de este repositorio. El criterio de verdad de las etiquetas es <strong>con qué grafía escribe este repositorio esa palabra de forma consistente</strong>. Es decir, no es el consenso de la comunidad de documentación técnica coreana, sino la costumbre de este blog; en las palabras donde ambas cosas divergen, el modelo puede acertar según el criterio de la comunidad y fallar según este.</p>
<ul>
<li><strong>Conjunto de la compuerta, 102 frases</strong>: Son palabras que la compuerta ya conoce. Los positivos son 23 <strong>frases contrafácticas</strong> en las que palabras que el texto escribe en inglés, como <code>calendar</code>, <code>picker</code> o <code>adapter</code>, se han devuelto a su transcripción en hangul; los negativos son 79 frases en las que aparecen de verdad palabras que <code>write-post.md</code> declara como excepción, como <code>리렌더링</code> o <code>콜 스택</code>. Aquí la compuerta de expresiones regulares actual acierta el 100% por definición.</li>
<li><strong>Conjunto en reserva, 60 frases</strong>: <strong>Son palabras que la compuerta no ha visto nunca.</strong> Es lo que en machine learning se llama conjunto <span class="glossary-term-source" data-glossary-key="held-out-set">held-out</span>. Los positivos son 30 casos en los que <code>loader</code>, <code>mutation</code> y <code>prefill</code>, que el texto escribe solo en inglés, se han devuelto a su transcripción; los negativos son 30 casos con <code>리듀서</code>, <code>스냅샷</code> y <code>런타임</code>, que el texto escribe solo en hangul. Aquí la expresión regular no atrapa ni un solo positivo.</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="Diagrama del conjunto compuerta de 102 frases y el conjunto reservado de 60 frases, su composición de positivos y negativos, y cómo la compuerta regex cubre todo el primero pero no atrapa ningún positivo del segundo" style="max-width:min(720px, 100%);height:auto;" width="1560" height="860" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Dejo dicho de entrada el desequilibrio de que los positivos son frases contrafácticas escritas por mí y los negativos son frases reales. Y <strong>la muestra efectiva no es el número de frases, sino el número de palabras.</strong> Como la etiqueta se decide por palabra, las frases en las que aparece la misma palabra no son observaciones independientes. El conjunto de la compuerta tiene 24 palabras distintas y el conjunto en reserva, 13.</p>
<p>La tabla de abajo agrega solo una pregunta <code>noul</code>, la que pregunta «¿hay que devolver esta palabra al inglés?». Metí cuatro preguntas en la misma petición, pero las otras tres eran la forma negativa, una variante de <code>Choice</code> y un juicio sobre compuestos cuya etiqueta no conseguí fijar bien, así que no las mezclo aquí. La <strong>proporción de automatización con presupuesto del 5%</strong> es la proporción máxima que se obtiene cortando desde la confianza más alta hacia abajo sin que el error del tramo superior pase del 5%.</p>
<table>
<thead>
<tr>
<th></th>
<th>Conjunto de la compuerta</th>
<th>Conjunto en reserva</th>
</tr>
</thead>
<tbody>
<tr>
<td>Frases / palabras</td>
<td>102 / 24 tipos</td>
<td>60 / 13 tipos</td>
</tr>
<tr>
<td>Compuerta de expresiones regulares actual</td>
<td><strong>1.000</strong></td>
<td>0.500</td>
</tr>
<tr>
<td>Exactitud de Kev-9B</td>
<td>0.225</td>
<td>0.500</td>
</tr>
<tr>
<td>Exactitud por palabra</td>
<td>6/24</td>
<td>7/13</td>
</tr>
<tr>
<td>Línea base de clase mayoritaria</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>Confianza media</td>
<td>0.876</td>
<td>0.821</td>
</tr>
<tr>
<td>Proporción con confianza de 0.9 o más</td>
<td>0.490</td>
<td>0.317</td>
</tr>
<tr>
<td>Acierto real de ese tramo</td>
<td>0.240</td>
<td>0.526</td>
</tr>
<tr>
<td><strong>Automatización con presupuesto del 5%</strong></td>
<td><strong>0.010</strong></td>
<td><strong>0.000</strong></td>
</tr>
</tbody>
</table>
<p>La línea base de clase mayoritaria es la puntuación que se obtiene sin leer la frase, estampando siempre la etiqueta más frecuente. El modelo queda por debajo de eso.</p>
<p>La última fila es la respuesta de este experimento. <strong>En el conjunto en reserva, por muy alto que pusiera el umbral, no hubo ni una sola decisión que pudiera procesarse automáticamente dentro de un 5% de error.</strong></p>
<p>Mirando la distribución de predicciones se ve qué ocurrió. El modelo respondió <strong>«hay que devolverla» en las 162 frases.</strong> En las 37 palabras distintas, todas. Acertó todos los positivos y falló todos los negativos. Por eso el 0.500 del conjunto en reserva no es habilidad: es la cifra que sale porque el conjunto está equilibrado.</p>
<p>Y con eso la confianza media era 0.876. En el conjunto de la compuerta, el 49% superó una confianza de 0.9, y el acierto real de ese tramo es 0.240.</p>
<p>Tampoco se cumplió la identidad estructural. Al sumar las probabilidades de «¿hay que devolverla?» y «¿hay que dejarla como está?», en el conjunto de la compuerta salió una media de 1.655, y las 102 frases se desviaron de 1 en más de 0.1.</p>
<p>Es así por diseño. Las dos preguntas son evaluaciones separadas que no se leen entre sí, y la propia TypeSafe incluye en su <a href="https://docs.typesafe.ai/model-jaggedness/jev-1.13" target="_blank" rel="noopener noreferrer">página sobre jaggedness</a> un ejemplo en el que <code>refund</code> 0.72 y <code>not_refund</code> 0.47 suman 1.19.</p>
<p>El punto que engancha en la práctica es este. <strong>Un umbral ajustado para <code>Noul</code> no debe trasladarse a <code>Choice</code>.</strong> Siendo el mismo juicio, las frases en las que la conclusión difería fueron entre el 14% y el 17%.</p>
<p>Si fuera un modelo que dice que sí a cualquier cosa que le preguntes, este resultado no significaría nada. Empecé por comprobar eso.</p>
<table>
<thead>
<tr>
<th>Estado / pregunta</th>
<th>Qué pregunté</th>
<th>Respuesta</th>
</tr>
</thead>
<tbody>
<tr>
<td>Coreano / coreano</td>
<td>이 문장은 영어로 쓰여 있는가? (¿Esta frase está escrita en inglés?)</td>
<td>0.02</td>
</tr>
<tr>
<td>Coreano / coreano</td>
<td>이 문장은 요리법을 설명하는가? (¿Esta frase explica una receta de cocina?)</td>
<td>0.04</td>
</tr>
<tr>
<td>Coreano / coreano</td>
<td>이 문장은 한국어로 쓰여 있는가? (¿Esta frase está escrita en coreano?)</td>
<td>0.98</td>
</tr>
<tr>
<td>Coreano / inglés</td>
<td>Is this sentence written in English?</td>
<td>0.01</td>
</tr>
<tr>
<td>Inglés / inglés</td>
<td>Is this sentence about software?</td>
<td>0.96</td>
</tr>
</tbody>
</table>
<p><strong>Lee coreano, dice «no» y separa 0.02 de 0.98.</strong> No es un sesgo global de complacencia. Lo que esto descarta llega hasta ahí. Queda la posibilidad de que la redacción misma de la pregunta de la compuerta sea mala.</p>
<h2 id="el-resultado-de-meter-las-reglas-en-el-state"><a class="anchor" href="#el-resultado-de-meter-las-reglas-en-el-state">El resultado de meter las reglas en el state</a></h2>
<p>Entonces, ¿cambia algo si le doy directamente el conocimiento que hace falta para decidir? El método de adaptación a un dominio que recomienda la documentación de TypeSafe es este: sin tocar los pesos, envías el material de referencia dentro del <code>state</code>.</p>
<p>Cogí los párrafos de reglas que ya están escritos en <code>write-post.md</code>, los metí también en el <code>state</code> y volví a ejecutarlo. La entrada pasó de 100 tokens por frase a 450. <strong>El conjunto de la compuerta es una <span class="glossary-term-source" data-glossary-key="data-leakage">filtración</span></strong>, porque ese material de referencia enumera por su nombre tanto las palabras de la compuerta como las excepciones. Por eso ese lado es el grupo de control que sirve para ver «si al menos lee el material de referencia», y la prueba de verdad es el conjunto en reserva.</p>
<table>
<thead>
<tr>
<th></th>
<th>Compuerta (filtración)</th>
<th>Compuerta + reglas</th>
<th>Reserva</th>
<th>Reserva + reglas</th>
</tr>
</thead>
<tbody>
<tr>
<td>Exactitud de <code>noul</code></td>
<td>0.225</td>
<td>0.676</td>
<td><strong>0.500</strong></td>
<td><strong>0.500</strong></td>
</tr>
<tr>
<td>Exactitud por palabra</td>
<td>6/24</td>
<td></td>
<td>7/13</td>
<td><strong>5/13</strong></td>
</tr>
<tr>
<td>Positivos correctos</td>
<td>23/23</td>
<td>1/23</td>
<td>30/30</td>
<td>8/30</td>
</tr>
<tr>
<td>Negativos correctos</td>
<td>0/79</td>
<td>68/79</td>
<td>0/30</td>
<td>22/30</td>
</tr>
<tr>
<td>Automatización con presupuesto del 5%</td>
<td>0.010</td>
<td>0.314</td>
<td>0.000</td>
<td>0.050</td>
</tr>
<tr>
<td>Latencia mediana</td>
<td>2,924ms</td>
<td>6,903ms</td>
<td>2,908ms</td>
<td>6,918ms</td>
</tr>
</tbody>
</table>
<p>La latencia corresponde a inferencia local en un M2 Max, así que no está en el mismo eje que la latencia de la API de Jev que vimos antes. Lo que hay que mirar aquí no es el valor absoluto, sino el factor de 2.4 que aparece al meter el material de referencia.</p>
<p><strong>En el conjunto en reserva, que está equilibrado, la exactitud por frase pasó de 0.500 a 0.500: no se movió en absoluto.</strong> Por palabra incluso bajó, de 7/13 a 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="Antes y después de añadir el párrafo de reglas al state, en las 60 frases fuera de la compuerta. Antes acertó las 30 frases a revertir y falló las 30 a conservar; después acertó 8 y 22. El total de aciertos es 30 en ambos casos" style="max-width:min(720px, 100%);height:auto;" width="1560" height="820" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>El salto de 0.225 a 0.676 en el conjunto de la compuerta no es una mejora de habilidad. Antes respondía «se devuelve» en las 102 frases, y al darle el material de referencia solo respondió así en 12. Los negativos subieron de 0/79 a 68/79, pero los positivos se hundieron de 23/23 a 1/23. En ese conjunto los negativos ganan por 79 a 23, así que una constante del tipo «déjalo casi siempre» recibe automáticamente una puntuación más alta. <strong>No aprendió a distinguir: cambió la inclinación.</strong></p>
<p>Eso no significa que el modelo no lea las frases. Con las mismas frases de las mismas palabras, al darle el material de referencia las predicciones se dispersaron frase a frase. La desviación típica de <code>로더</code> subió de 0.096 a 0.217, y la de <code>뮤테이션</code> de 0.042 a 0.236. <strong>Lee, pero no distingue.</strong></p>
<p>Y este resultado también sacude la interpretación de la sección anterior. Si un solo texto del state da la vuelta por completo a la salida, el «devolverlas todas» de la primera ejecución podría no ser un límite del modelo, sino una carencia en la redacción de la pregunta. Este artículo no ha podido descartar esa posibilidad.</p>
<p>Una cosa sí mejoró de forma visible. La suma de las probabilidades de «se devuelve» y «se deja» se acercó, en el conjunto en reserva, de 1.508 a 0.963, y las frases que se alejaban mucho de 1 bajaron de 59 a 18. <strong>Pero la exactitud no se movió.</strong> Que las probabilidades de dos preguntas encajen entre sí y que esas probabilidades sean correctas son cosas distintas.</p>
<h2 id="el-lugar-de-los-modelos-de-decisión"><a class="anchor" href="#el-lugar-de-los-modelos-de-decisión">El lugar de los modelos de decisión</a></h2>
<p>Visto hasta aquí, es fácil inclinarse a concluir que toda esta categoría de modelos es inútil, pero poner uno al lado de otro los benchmarks publicados dice que no es así.</p>
<table>
<thead>
<tr>
<th>Tarea</th>
<th>n</th>
<th>Jev</th>
<th>Comparación</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://github.com/bitnovus/jev-spam-eval" target="_blank" rel="noopener noreferrer">Spam, dentro de la distribución</a></td>
<td>18,514</td>
<td>98.3%</td>
<td>Regresión TF-IDF 98.4%</td>
</tr>
<tr>
<td>Spam, fuera de la distribución</td>
<td>2,876</td>
<td><strong>98.6%</strong></td>
<td>Regresión TF-IDF <strong>73.0%</strong></td>
</tr>
<tr>
<td>Phishing</td>
<td>2,000</td>
<td>62.6%</td>
<td>Haiku 4.5 81.3%, línea base de reglas 91.6%</td>
</tr>
<tr>
<td><a href="https://github.com/anessbelbati/jev-rerank-bench" target="_blank" rel="noopener noreferrer">Rerank, 8 conjuntos en inglés</a></td>
<td>1,617</td>
<td>nDCG@10 0.692</td>
<td>Cohere Rerank 4 Pro 0.691</td>
</tr>
</tbody>
</table>
<p>Solo la fila de rerank es una métrica de calidad de ranking de búsqueda, así que está en un eje distinto del de la exactitud de las demás filas.</p>
<p><strong>Dentro de la distribución, una expresión regular o un clasificador ya ajustado gana o empata.</strong> Es el mismo lugar en el que, en mi conjunto de la compuerta, la expresión regular ganó por 1.000 a 0.225. Si puedes reunir etiquetas, entrenar un modelo pequeño sale más barato, más rápido y más exacto. Eso es algo que se hace desde hace mucho.</p>
<p><strong>La brecha se abre cuando la distribución es nueva y no hay etiquetas.</strong> En el spam <span class="glossary-term-source" data-glossary-key="out-of-distribution">fuera de la distribución</span>, 98.6% frente a 73.0%. El clasificador ajustado se viene abajo ante una forma que no ha visto nunca, y este aguanta. El lugar de los modelos de decisión son <strong>las decisiones para las que no se pueden reunir datos, etiquetarlos y entrenar</strong>. Es el sitio donde cada situación es nueva, no se logran reunir etiquetas y aun así hay que decidir en cuestión de segundos.</p>
<p>Este marco explica también por qué falló mi discriminación de transcripciones. Lo que esa decisión necesita no es razonamiento general, sino <strong>el conocimiento de dominio concreto de qué palabras se han asentado en la documentación técnica coreana</strong>, y para una implementación de reproducción de 9B entrenada con tareas de decisión en inglés eso no está fuera de la distribución: sencillamente no existe en ella. Ahí está la razón de que tampoco funcionara darle las reglas por escrito.</p>
<p>En un artículo de TechCrunch, Armin Ronacher, CTO de Earendil, la empresa que hace el harness Pi, <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">dijo lo siguiente</a>.</p>
<blockquote>
<p>At the end of the day, it delegates the hallucination problem a little bit to the user.</p>
</blockquote>
<p>No ha eliminado la alucinación: ha pasado la decisión al desarrollador. La documentación de TypeSafe también escribe que la calibración es una propiedad que se cumple sobre un conjunto de predicciones y no una garantía de que una respuesta individual sea correcta, y bajo la barra del 0% de tasa de alucinación del artículo de presentación, en el apartado Nuance, se lee "Our number is not empirical". El formato está garantizado; el contenido no.</p>
<h2 id="los-194-proyectos-de-jevable"><a class="anchor" href="#los-194-proyectos-de-jevable">Los 194 proyectos de jevable</a></h2>
<p>Como en mis datos falló, fui a ver dónde lo usan los demás. <a href="https://jevable.com/" target="_blank" rel="noopener noreferrer">jevable.com</a> es una curación independiente en la que un desarrollador llamado Nikunj revisa y reúne los proyectos con Jev que aparecen en X. A fecha de 23 de septiembre de 2026 son 194, y las fechas de alta se concentran entre el 16 y el 20 de septiembre de 2026. 152 entraron en un solo día, el 18 de septiembre. Tres días después del lanzamiento.</p>
<table>
<thead>
<tr>
<th>Categoría</th>
<th>Cantidad</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>Si en vez de por categoría se reagrupan por «qué se le pregunta», salen tres formas. Las cifras de abajo son las que cada autor escribió en su propia publicación.</p>
<p><strong>Primera, clasificación y enrutamiento.</strong> Clasificar 1,500 correos, clasificar documentos fiscales, clasificar 1,891 anuncios de la competencia en 19 segundos por $0.12, 14 typed checks sobre un único diff de PR, detectar publicidad en las notificaciones de Android, clasificar 26 planos de construcción en 2.9 segundos. Es la misma forma que la compuerta de transcripciones del principio. Como decía la tabla de la sección anterior, son lugares donde las etiquetas se acumulan cada día, así que con el tiempo un clasificador pequeño entrenado con tus propias etiquetas gana o empata. La ventaja de Jev está en el primer día, cuando no hay etiquetas.</p>
<p><strong>Segunda, bucles de selección de acciones.</strong> Un browser agent enganchado a Browser Use que terminó una búsqueda de vuelos en 7 segundos por $0.0039, un computer use que maneja el Mac por voz, un juego que cada vez que Mario muere hace fork de la VM en cuatro ramas y elige la que sobrevive, un motor de expresiones faciales que decide en cada mensaje diez cosas de un personaje 3D, como la boca, las cejas y la mirada. El action space cambia en cada paso, así que no se pueden reunir etiquetas, y la decisión tiene que bajar por debajo del segundo. Es el mismo lugar en el que la diferencia se abrió con el spam fuera de la distribución. Creo que también por eso Games es la categoría más grande, con 39. Un juego es un bucle de selección de acciones en el que equivocarse tiene vuelta atrás.</p>
<p><strong>Tercera, interfaces que muestran la probabilidad al usuario.</strong> Ask Jev, que devuelve solo el veredicto en lugar de una respuesta; JevForm, un formulario ramificado que elige la siguiente pregunta por probabilidad; Upweight, que reordena la portada de Hacker News con seis deslizadores como profundidad técnica y drama. Como el umbral no está metido en el código y es una persona quien lee la probabilidad, es donde menos pesa la exigencia de calibración que este artículo ha puesto en cuestión.</p>
<p>Una cosa llama la atención. De las 194 descripciones, 30 anotan el coste y 53 la velocidad, pero solo 7 anotan la exactitud o una línea de base. Los resúmenes solo recogen el inicio de cada post, así que es una cota inferior, pero la dirección está clara. Que es rápido y barato se sabe el primer día; si acierta, solo se sabe midiendo, y los que miden son pocos. Uno de esos 7 es <code>jevcal</code>. Su autor dice que todo el mundo elige el umbral a ojo, y la herramienta devuelve el umbral y la proporción de procesamiento automático a partir de tus datos y la exactitud objetivo. Es exactamente el procedimiento que recomienda la sección siguiente, convertido en herramienta.</p>
<h2 id="cómo-trazar-la-línea"><a class="anchor" href="#cómo-trazar-la-línea">Cómo trazar la línea</a></h2>
<p>La conclusión de este artículo es esta. <strong>No leas como una especificación la probabilidad que devuelve el modelo: mídela tú mismo sobre tus propios datos y traza ahí la línea.</strong></p>
<p>El procedimiento para medir es este. Reúnes un centenar largo de casos etiquetados, construyes una tabla de tasa real de acierto por tramo de probabilidad y vas bajando el umbral desde arriba mirando la tasa de error del tramo superior. Si has fijado un presupuesto de error, la cobertura máxima que respeta ese presupuesto es justamente la proporción que puedes automatizar. Que en mi experimento el acierto real del tramo con confianza de 0.9 o más fuera 0.240 salió a la luz con 102 frases.</p>
<p><strong>Una escala desajustada suele poder arreglarse.</strong> Si el exceso de confianza está repartido de forma uniforme por todos los tramos, una sola temperatura lo corrige casi del todo. Kev hizo justamente eso y bajó su ECE de 0.106 a 0.042. El problema es que ajustar esa temperatura exige tener las etiquetas de esa distribución, y eso es exactamente lo que no hay en producción. Además, la temperatura no puede cambiar el orden de las probabilidades, así que la proporción automatizable no sube en la misma medida. Por eso el orden importa más que la escala, y la cobertura bajo presupuesto de error es una métrica más práctica que el ECE.</p>
<p>Si hubiera clavado en el código el 0.9 que la documentación usa en su ejemplo, esta compuerta habría corregido mal, en silencio, 38 casos. Como no salta ningún error, es un fallo difícil de advertir. La documentación de TypeSafe también escribe, justo debajo de ese ejemplo, que lo pruebes con tus propios datos. Lo que acaba clavado en el código no suele ser la frase de debajo, sino el número de arriba.</p>
<p>Aunque no tengas clave, medir puede empezarse hoy mismo. Kev está publicado bajo Apache-2.0 y corre en un portátil.</p>
<p>Me gustaría que contaras cuántas llamadas del servicio que operas le preguntan solo sí/no a un modelo grande. Antes de mover esas llamadas a un modelo de decisión, te recomiendo medir primero dónde habría que trazar la línea después de moverlas. Yo pude decidir no tocar la compuerta porque respeté ese orden. Cuando consiga una clave de Jev pienso volver a pasar el mismo conjunto, y si entonces cambia la conclusión, también lo escribiré.</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: el bautizo de 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, juicio de riesgo en llamadas a herramientas</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>IA</category>
            <category>Calibración</category>
        </item>
        <item>
            <title><![CDATA[overlay-kit]]></title>
            <link>https://hooninedev.com/es/260921</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260921</guid>
            <pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de las interfaces que tratan los overlays de forma declarativa. Me gusta el código declarativo. Abrir un overlay, cerrarlo y hacer trabajo asíncrono dentro de él es una ...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de las interfaces que tratan los overlays de forma declarativa.</p>
<p>Me gusta el código declarativo. Abrir un overlay, cerrarlo y hacer trabajo asíncrono dentro de él es una secuencia que, en mi opinión, debería gestionar el propio overlay. Cuando quien llama guarda <code>isOpen</code> y lo enciende y apaga, el código que maneja el estado acaba en un lugar distinto del componente que lo dibuja.</p>
<p>Pero los modales de las pantallas de las que me ocupaba en la empresa eran justo lo contrario. Si un modal estaba abierto vivía en un store global, quien llamaba lo abría y quien llamaba lo cerraba. También era quien llamaba el que escribía el código que cerraba el modal y los callbacks que recibían su resultado. Por eso estuve mucho tiempo pensando cómo llevar esto hacia algo declarativo, y al final lo reescribí.</p>
<p>Mientras hacía ese trabajo miré <a href="https://github.com/toss/overlay-kit" target="_blank" rel="noopener noreferrer">overlay-kit</a>. Se parecía a la interfaz que yo buscaba. Este artículo recoge la interfaz declarativa de overlay-kit y cómo diseñé el sistema de modales interno.</p>
<h2 id="el-modal-que-guardaba-en-estado"><a class="anchor" href="#el-modal-que-guardaba-en-estado">El modal que guardaba en estado</a></h2>
<p>En mi artículo sobre <a href="/260518" target="_blank" rel="noopener noreferrer">gestión de estado</a> puse el indicador de apertura de un modal como el ejemplo más simple de estado local. Escribí que es estado que se usa dentro de un solo componente y que el exterior no necesita ni tiene derecho a conocer. Sigo pensando que es cierto. Pero en cuanto tienes decenas de tipos de modal, se superponen entre sí y deben devolver un valor a quien los llamó, ese estado deja de quedarse dentro del componente.</p>
<p>El código que heredé guardaba la lista de modales abiertos en un store global. Como es código de la empresa, el ejemplo siguiente conserva la estructura y cambia los identificadores.</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>Tres cosas de este código me incomodaban.</p>
<p><strong>Primera: hay que sacar la función de apertura de un hook.</strong> <code>openPopup</code> y <code>closePopup</code> son valores que se seleccionan del store, así que solo se pueden obtener dentro de un componente. Eso significa que el código que abre un modal no puede salir del componente. Para abrir un modal desde un sitio donde no puedes usar hooks, como un interceptor de respuestas de la API o un guard del router, tienes que ir pasando esa función como argumento.</p>
<p><strong>Segunda: el resultado vuelve por callbacks.</strong> Si se guardó la nota o si el usuario simplemente cerró el diálogo llega por dos ramas distintas, <code>onSubmit</code> y <code>onClose</code>. Todo lo que quieras hacer después de abrir el modal entra dentro de un callback. Y si tienes que abrir dos modales seguidos, aparece un callback dentro de otro callback.</p>
<p><strong>Tercera: cerrar es responsabilidad de quien llama.</strong> Es quien llama el que invoca <code>closePopup(PopupType.ADD_NOTE)</code>. El modal no se cierra a sí mismo: el lado que lo abrió recuerda de qué tipo de modal se trata y lo cierra. Por eso la misma constante aparece tres veces en una sola función.</p>
<p>Lo que las tres tienen en común es que abrir un único modal obliga a quien llama a escribir tres clases de código: el que lo abre, el callback que recibe el resultado y el que lo cierra.</p>
<p>Si decidir qué mostrar es tarea de quien llama, aquí quien llamaba cargaba además con todo lo que venía después. En particular, <code>closePopup(PopupType.ADD_NOTE)</code> señala su objetivo con una constante de texto. Escribir la constante de otro modal sigue pasando la comprobación de tipos porque pertenece al mismo enum, y se cierra el modal equivocado.</p>
<h2 id="una-interfaz-que-espera-un-resultado"><a class="anchor" href="#una-interfaz-que-espera-un-resultado">Una interfaz que espera un resultado</a></h2>
<p>Entonces, ¿cuál de las tres se puede quitar? La respuesta a la que llegué fue convertir la apertura de un modal en una llamada a función y hacer que esa función devuelva una Promise con el resultado.</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>Cambiaron tres cosas. La función de apertura pasó a ser una función de módulo en lugar de un hook, así que se puede llamar fuera de un componente. El resultado pasó a ser un valor de retorno en lugar de un argumento de callback, así que el trabajo posterior se quedó dentro del flujo de la función que llama. Y cerrar pasó a ser tarea del propio componente del modal.</p>
<p>Como el tipo de retorno es <code>Promise&#x3C;ModalResult&#x3C;V>></code>, el final del modal se concentró en un único valor.</p>
<p>Una Promise termina una sola vez. Resolver una Promise ya resuelta no hace nada, así que todos los motivos por los que un modal puede terminar tienen que caber en ese único valor. Por eso dividí <code>reason</code> en cinco casos: el usuario confirmó, el usuario canceló explícitamente, se cerró sin valor, fue reemplazado por un modal nuevo del mismo tipo, o se cerró a la fuerza en una limpieza general. Con un solo booleano, «cancelado» y «se desmontó la pantalla entera» serían el mismo valor, y en los flujos que necesitan limpieza hay que distinguir esos dos casos.</p>
<p>En la dirección contraria, si nunca la terminas, quien llama espera para siempre. No pasa nada cuando un callback no se invoca, pero un <code>await</code> simplemente no regresa. Por eso, cada vez que aparecía un camino nuevo para quitar un modal de la pantalla, tenía que comprobar si ese camino también terminaba la Promise. El reemplazo y el cierre forzado tienen cada uno su propio <code>reason</code> justamente por eso.</p>
<h2 id="el-punto-de-llamada-de-overlay-kit"><a class="anchor" href="#el-punto-de-llamada-de-overlay-kit">El punto de llamada de overlay-kit</a></h2>
<p>El punto de llamada de overlay-kit, de toss, tiene este aspecto.</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>Hay tres puntos que me parecieron una buena interfaz.</p>
<p><strong>Qué se abre está en el punto de llamada.</strong> Mi versión pasa una clave de texto, <code>'ADD_NOTE'</code>. Qué componente es esa clave lo sabe un archivo de registry: un archivo que contiene un único objeto que empareja cada clave de texto con la función <code>import()</code> que carga ese 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 escribe el JSX ahí mismo. Quien lee el código sabe qué va a aparecer sin tener que ir a ese archivo.</p>
<p><strong>El tipo del resultado se decide en el punto de llamada.</strong> El argumento de tipo de <code>openAsync&#x3C;boolean></code> es a la vez el tipo del argumento de <code>close</code> y el tipo del resultado del <code>await</code>. No hace falta declarar un tipo de resultado por modal y registrarlo en una tabla.</p>
<p><strong>Cerrar y eliminar están separados.</strong> El controlador recibe cuatro props: <code>overlayId</code>, <code>isOpen</code>, <code>close</code> y <code>unmount</code>. <code>close</code> pone <code>isOpen</code> en <code>false</code> y deja el componente donde está. <code>unmount</code> es lo que realmente lo elimina. <a href="https://overlay-kit.slash.page/ko/docs/guides/introduction" target="_blank" rel="noopener noreferrer">La guía oficial sobre abrir y cerrar overlays</a> lo explica así.</p>
<blockquote>
<p>Esta diferencia se debe a que, al usar <code>close</code>, el overlay se mantiene en memoria para poder mostrar su animación de cierre.</p>
</blockquote>
<p>Mi versión no tiene esa distinción. Al cerrar, sale de la lista de inmediato. Eso significa que estaba dejando las animaciones de cierre en manos de la librería de componentes, y que solo trataba con pantallas donde eso era posible.</p>
<h2 id="de-overlayopen-al-render"><a class="anchor" href="#de-overlayopen-al-render">De overlay.open al render</a></h2>
<p>Cuando te gusta una interfaz, lo siguiente que te preguntas es qué hay dentro. Para que una sola función de módulo dibuje un componente dentro del árbol de React, algo tiene que unir ambos lados.</p>
<h3 id="eventos-y-reducer"><a class="anchor" href="#eventos-y-reducer">Eventos y reducer</a></h3>
<p><code>overlay.open</code> no cambia el estado. Emite un evento.</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>Quien lo recibe es <code>OverlayProvider</code>. El provider tiene un <code>useReducer</code>, se suscribe al evento y mete <code>ADD</code> en el reducer. Lo que hay que mirar con atención aquí es que <code>isOpen</code> entra como <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>Y en el frame siguiente al montaje del controlador mete <code>OPEN</code> para poner <code>isOpen</code> en <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>Aquí hubo una parte que no coincidió con lo que esperaba.</p>
<p><strong>El estado no está fuera de React.</strong> Como es una API que abre mediante una función de módulo, supuse que el store también estaría a nivel de módulo, pero en realidad es un <code>useReducer</code> dentro del provider. Lo único que está fuera de React es el bus de eventos, y ese no guarda estado. Los overlays se renderizan como hermanos junto a los <code>children</code> del provider. No usa <code>createPortal</code> ni importa <code>react-dom</code>.</p>
<p>Hay algo más que llama la atención. Desde el punto de llamada es una sola línea, <code>overlay.open()</code>, pero dentro hay un procedimiento con un orden fijo: emitir un evento, meter <code>ADD</code> en el reducer, esperar un frame y volver a meter <code>OPEN</code>. Una interfaz declarativa no elimina el código imperativo: lo mueve al otro lado de una frontera.</p>
<h3 id="el-lado-que-abre-y-el-lado-que-cierra"><a class="anchor" href="#el-lado-que-abre-y-el-lado-que-cierra">El lado que abre y el lado que cierra</a></h3>
<p>El <code>requestAnimationFrame</code> que acabamos de ver y la separación entre <code>close</code> y <code>unmount</code> de la sección anterior parecen cosas distintas a primera vista. En realidad son dos mecanismos que tapan los dos extremos de la misma restricción.</p>
<p>Esa restricción es que una transición de CSS solo funciona si <strong>tanto el estado inicial como el final se renderizan de verdad</strong>. <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_transitions/Using_CSS_transitions" target="_blank" rel="noopener noreferrer">La guía de transiciones CSS de MDN</a> advierte sobre los elementos recién añadidos al 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>El fallo aparece con dos formas. Al abrir no hay estado inicial, así que no se ejecuta; al cerrar el elemento desaparece antes de que se pinte el estado final, así que tampoco.</p>
<table>
<thead>
<tr>
<th></th>
<th>Al abrir</th>
<th>Al cerrar</th>
</tr>
</thead>
<tbody>
<tr>
<td>Problema</td>
<td>No hay estado inicial</td>
<td>Desaparece antes de pintar el estado final</td>
</tr>
<tr>
<td>Solución</td>
<td>Montarlo cerrado primero y abrirlo en el frame siguiente</td>
<td>Mantenerlo montado, apagar solo <code>isOpen</code> y eliminarlo después</td>
</tr>
<tr>
<td>Mecanismo</td>
<td><code>ADD(isOpen: false)</code>, rAF, <code>OPEN</code></td>
<td><code>CLOSE</code>, animación, <code>REMOVE</code></td>
</tr>
<tr>
<td>Quién conoce el momento</td>
<td>La librería</td>
<td>La persona que la usa</td>
</tr>
</tbody>
</table>
<p>La última fila explica por qué el lado que cierra queda en manos de quien usa la librería. En el lado que abre, la librería conoce la constante: basta con un frame. En el lado que cierra, la librería no puede saber si la animación dura 200 o 400 milisegundos.</p>
<h4 id="el-cierre-entregado-como-unmount"><a class="anchor" href="#el-cierre-entregado-como-unmount">El cierre entregado como <code>unmount</code></a></h4>
<p>Lo que sostiene el lado que cierra está en el reducer. <code>CLOSE</code> solo invierte, dentro de <code>overlayData</code>, el valor <code>isOpen</code>, y no toca <code>overlayOrderList</code>. El provider renderiza recorriendo esa lista, así que el componente sigue vivo mientras permanezca en ella. Solo se desmonta cuando <code>REMOVE</code> lo saca.</p>
<p><strong>Dicho esto, rAF no siempre es necesario.</strong> react-transition-group <a href="https://reactcommunity.org/react-transition-group/transition" target="_blank" rel="noopener noreferrer">no ejecuta la transición de entrada en el primer montaje de forma predeterminada</a>. La salta incluso cuando <code>in</code> ya es <code>true</code>, y hay que activar <code>appear</code>. El rAF de overlay-kit cambia <code>isOpen</code> de <code>false</code> a <code>true</code> después del montaje, lo que cubre ese caso.</p>
<h3 id="veinte-líneas-envueltas-en-una-promise"><a class="anchor" href="#veinte-líneas-envueltas-en-una-promise">Veinte líneas envueltas en una Promise</a></h3>
<p><code>openAsync</code> no es una implementación aparte, sino una capa fina alrededor de <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>Todo lo que hace es sustituir el <code>close</code> que llega al controlador. El <code>close</code> original no recibe argumentos; este recibe uno, resuelve la Promise con él y después llama al <code>close</code> original. Esta es también la razón por la que el tipo del resultado se decide en el punto de llamada. <code>T</code> es a la vez el tipo del argumento de <code>close</code> y el tipo de la Promise.</p>
<p>Aun así, esta estructura tiene un agujero. La resolución solo ocurre cuando se llama a <code>close</code>. Por eso, si quitas un overlay únicamente con <code>unmount</code>, la Promise se queda pendiente para siempre. Está registrado en el <a href="https://github.com/toss/overlay-kit/issues/169" target="_blank" rel="noopener noreferrer">issue #169</a> y creo que hay que arreglarlo, pero el issue sigue abierto.</p>
<p>Que la resolución ocurra en <code>close</code> tiene otra implicación. Es el momento en que empieza el cierre, no el momento en que termina la animación de cierre. Eso da igual en un flujo que pide confirmación y luego llama a una API, pero en un flujo que pide confirmación y luego abre el siguiente modal, el segundo sube mientras el primero todavía está desapareciendo.</p>
<h3 id="donde-el-diseño-dio-la-vuelta-completa"><a class="anchor" href="#donde-el-diseño-dio-la-vuelta-completa">Donde el diseño dio la vuelta completa</a></h3>
<p>Seguí el historial de commits para explorar cómo llegó aquí el diseño, y descubrí que la estructura no fue así desde el principio. La 1.0 usaba la misma combinación de eventos y reducer que hoy. La 1.2.0 movió el estado a un store externo a nivel de módulo que se leía con <code>useSyncExternalStore</code>, y la 1.8.0 lo devolvió a un reducer.</p>
<p>El motivo de la vuelta atrás está escrito en el <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>Cuando obtener datos dentro de un overlay lo hacía suspender, React avisaba de que un componente había suspendido mientras respondía a una entrada síncrona. El objetivo original de pasar a un store externo era poder leer el estado del overlay sin un hook, y el PR de entonces dice sin rodeos que el objetivo no se logró de la forma prevista. Al final el objetivo se resolvió con un hook y solo el mecanismo de almacenamiento volvió a su sitio original.</p>
<p>Lo que me llevo de este registro no es una conclusión, sino un criterio. Poner el estado fuera de React libera desde dónde puedes llamar, a cambio de abrir la puerta a desencajarse de la planificación de React. overlay-kit dejó fuera solo el punto de llamada y mantuvo el estado dentro.</p>
<h2 id="al-reabrir-con-el-mismo-id"><a class="anchor" href="#al-reabrir-con-el-mismo-id">Al reabrir con el mismo id</a></h2>
<p>Algo me llamó la atención leyendo el reducer. Justo al principio de <code>ADD</code> hay esta rama.</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>Si abres un overlay indicando un <code>overlayId</code>, haces <code>close</code> y luego lo vuelves a abrir con el mismo id, entras en esta rama. Pero el valor de retorno reutiliza el <strong>objeto <code>overlay</code> existente</strong>.</p>
<p>Nada usa nunca el <code>action.overlay</code> entrante ni su <code>controller</code>.</p>
<p>Leerlo no me bastaba para estar seguro, así que cloné el repositorio de la librería y le añadí una prueba yo mismo. Preparé dos componentes que dibujan contenidos distintos y los abrí en orden con el mismo 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>Estos fueron los resultados, ejecutados con vitest sobre la rama main en la versión 1.9.0 de overlay-kit.</p>
<table>
<thead>
<tr>
<th>Procedimiento</th>
<th>Lo que se renderizó</th>
</tr>
</thead>
<tbody>
<tr>
<td>Abrir A, <code>close</code>, reabrir con B</td>
<td><code>AAA</code>. B se descarta</td>
</tr>
<tr>
<td>Abrir A, <code>unmount</code>, reabrir con B</td>
<td><code>BBB</code>. Se reemplaza correctamente</td>
</tr>
<tr>
<td>Subir un contador a 1, <code>close</code>, reabrir</td>
<td><code>1</code>. El estado del componente se conserva</td>
</tr>
</tbody>
</table>
<p>La tercera fila es un comportamiento que la documentación describe como una función, así que salió como esperaba. El problema es la primera fila.</p>
<h3 id="el-jsx-nuevo-se-ignora"><a class="anchor" href="#el-jsx-nuevo-se-ignora">El JSX nuevo se ignora</a></h3>
<p>Aquí comprobé una cosa más. Más abajo, en el mismo reducer, hay este comentario.</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>Dice que reabrir después de cerrar trae el overlay al frente. Pero como la rama anterior llega antes a <code>return</code>, esta línea no se ejecuta en esa situación. Apilé dos overlays, hice <code>close</code> sobre el de abajo, lo volví a abrir y el orden del DOM no cambió.</p>
<h3 id="un-comportamiento-que-contradice-el-comentario"><a class="anchor" href="#un-comportamiento-que-contradice-el-comentario">Un comportamiento que contradice el comentario</a></h3>
<p>Eso sí, no comprobé si esto se manifiesta como un problema visible en pantalla. La prueba solo miró el orden del DOM y, como la mayoría de las librerías de diálogos usan su propio z-index o portales, el orden del DOM no equivale al orden visual.</p>
<p>Mi versión expone esta misma situación como una opción. Al reabrir un modal del mismo tipo, quien llama elige si conservar el existente o reemplazarlo con props nuevas. Si elige el reemplazo, la Promise existente termina como <code>replaced</code>. Esta es una de las razones por las que antes dividí el tipo del resultado en cinco ramas.</p>
<p><strong>Aquí es donde divergen los dos diseños.</strong> Mi enfoque trata los modales como datos. Como la clave de texto y las props viven en un store, se pueden reemplazar o actualizar más tarde. overlay-kit trata los modales como funciones. El controlador es un closure, así que una vez guardado no hay forma de cambiar su contenido desde fuera.</p>
<h2 id="el-precio-del-jsx-en-el-punto-de-llamada"><a class="anchor" href="#el-precio-del-jsx-en-el-punto-de-llamada">El precio del JSX en el punto de llamada</a></h2>
<p>Entonces, ¿escribir JSX en el punto de llamada es siempre mejor que usar una clave de texto? Creo que no. Hay tres precios que pagar.</p>
<p><strong>La división de código deja de ser lo predeterminado.</strong> Escribir JSX en el punto de llamada significa que quien llama importa ese componente. Si el modal es una pantalla de edición pesada y solo unos pocos usuarios pulsan ese botón, ese peso entra en el bundle de la pantalla de listado.</p>
<p><code>React.lazy</code> sí funciona dentro de un controlador. Lo medí igual que en la prueba anterior: el módulo no se cargaba antes de <code>open</code> y, al abrirlo, primero aparecía el fallback de <code>Suspense</code> y después se renderizaba el contenido.</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>Así que la diferencia no está en si es posible, sino en cuál es el valor predeterminado.</p>
<p>En mi versión todos los valores del archivo de registry son funciones <code>import()</code>, así que la división de código queda forzada por la estructura. Con overlay-kit hay que escribirla a mano en cada punto de llamada.</p>
<p><strong>Quien usa la librería tiene que decidir dónde llamar a <code>unmount</code>.</strong> Si solo llamas a <code>close</code> y te quedas ahí, la información del overlay permanece en memoria. La documentación oficial advierte de esto y da indicaciones separadas sobre dónde enganchar <code>unmount</code> en cada sistema de diseño. MUI y Mantine tienen un callback para cuando termina la transición, así que basta con engancharlo ahí, pero para las librerías que no lo tienen la documentación dice que pongas un temporizador con la duración de la animación. Que esas indicaciones estén repartidas por librería es en sí mismo el coste de esta decisión.</p>
<p><strong>Los overlays se renderizan donde está el provider.</strong> Como no usa portales, el mismo código funciona en React Native, lo cual es una ventaja. A cambio, el contexto proporcionado por debajo de <code>OverlayProvider</code> no es visible dentro de un overlay. Al abrir un modal que depende del contexto del router o de un formulario, conviene comprobar esto primero.</p>
<h2 id="hasta-dónde-llega-la-palabra-declarativo"><a class="anchor" href="#hasta-dónde-llega-la-palabra-declarativo">Hasta dónde llega la palabra declarativo</a></h2>
<p>Después de escribir hasta aquí, hay algo que me inquieta. Empecé este artículo llamando declarativa a esta interfaz, y sin embargo <code>overlay.openAsync(...)</code> es una llamada a función se mire como se mire.</p>
<p>Empecemos por la definición. <a href="https://react.dev/learn/reacting-to-input-with-state" target="_blank" rel="noopener noreferrer">La documentación de React</a> traza la línea así.</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>La analogía del mismo documento es más nítida. Lo imperativo es ir cantando cada giro a quien conduce el coche; lo declarativo es subirse a un taxi y decir el destino.</p>
<p><strong>Una interfaz declarativa no elimina el procedimiento imperativo: lo mueve al otro lado de una frontera y le pone nombre a esa frontera.</strong> El interior de overlay-kit que hemos visto es exactamente eso. El punto de llamada es una línea, mientras que dentro corre un procedimiento que emite un evento y hace dos dispatch.</p>
<p>Veamos entonces dónde aplica cada proyecto la palabra. La documentación de overlay-kit llama imperativo al enfoque anterior. Lo que señala es el código que guarda el indicador de apertura con <code>useState</code> y lo invierte en un manejador de eventos. Lo que llama declarativo es el lado que trata el overlay como una acción y no como estado. El <a href="https://toss.tech/article/frontend-declarative-code" target="_blank" rel="noopener noreferrer">artículo sobre código declarativo</a> del blog técnico de toss define la palabra de forma más amplia: el código declarativo es código con un nivel de abstracción más alto, y un hook que abstrae la acción de mostrar un overlay es un ejemplo de ello.</p>
<p>Por eso decidí estrechar la palabra así: <strong>quien llama no escribe el código que cierra</strong>. Cuándo cerrar, y qué queda en pantalla mientras se cierra, lo decide el overlay; quien llama escribe solo qué mostrar y qué espera recibir de vuelta.</p>
<p>Con este criterio, el código escrito con <code>useState</code> que guarda <code>isOpen</code> y el código que mantiene una lista de modales abiertos en un store global mientras quien llama los cierra están del mismo lado. Solo cambia dónde se guarda; en ambos, quien llama escribe la instrucción de cierre.</p>
<h2 id="para-terminar"><a class="anchor" href="#para-terminar">Para terminar</a></h2>
<p>Lo que arreglé en el código de la empresa no fue, al final, dónde se guardaba el estado. Fue quién escribe el código que cierra.</p>
<p>Incluso cuando usábamos un store global, el estado ya estaba fuera del componente, pero las instrucciones que abrían y cerraban seguían en el punto de llamada. Solo tras cambiar a un único <code>await</code> esas instrucciones se metieron dentro del modal.</p>
<p>overlay-kit llegó al mismo lugar y luego dio un paso más: pone también en el punto de llamada qué se muestra. Creo que esa interfaz se lee bien. Y, al escribir esto, aprendí cuánto cuesta ese paso.</p>
<p><strong>Era la diferencia entre tratar un modal como una función y tratarlo como datos.</strong> Escribir JSX en el punto de llamada convierte al controlador en un closure, y el contenido de un closure no se puede cambiar desde fuera una vez guardado. De esa estructura sale el JSX ignorado al reabrir con el mismo id. Tratarlo con una clave de texto convierte al modal en un valor guardado en un store que puedes reemplazar, actualizar y dividir en chunks, pero quien llama solo llega a conocer un nombre.</p>
<p>Creo que la sensación de que te gusta la interfaz de una librería es una señal bastante fiable. Aun así, conviene poner en palabras al menos una vez a qué apunta exactamente esa sensación. Si no lo haces, no puedes distinguir si lo que te gustó fue la interfaz o la reputación de quienes usan la librería. Animo a quien lea esto a hacer ese ejercicio una vez con alguna librería que esté usando ahora mismo.</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">Documentación oficial de overlay-kit</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>bibliotecas</category>
        </item>
        <item>
            <title><![CDATA[Calibración y exceso de confianza en los LLM]]></title>
            <link>https://hooninedev.com/es/260917</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260917</guid>
            <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de la calibración de los modelos y del exceso de confianza que aparece después de RLHF. Está pensado para desarrolladores que quieren usar en su código, como criterio de...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de la calibración de los modelos y del exceso de confianza que aparece después de RLHF. Está pensado para desarrolladores que quieren usar en su código, como criterio de decisión, la probabilidad o la confianza que devuelve un modelo. Al terminar podrás explicar en qué se diferencian la exactitud y la calibración, qué mide el ECE, cómo las probabilidades de un modelo pulido con preferencias humanas se separan de su tasa real de acierto y hasta dónde se conoce la causa.</p>
<p>Tuve que ordenar primero estos conceptos mientras ponía a prueba las afirmaciones de Jev, un modelo de decisión presentado por TypeSafe AI. En lugar de frases, Jev devuelve una probabilidad para cada opción y presenta la calibración como su objetivo de entrenamiento. Para examinar esa afirmación, primero necesitaba saber qué es la calibración y por qué se desajusta en los modelos existentes.</p>
<h2 id="exactitud-y-calibración"><a class="anchor" href="#exactitud-y-calibración">Exactitud y calibración</a></h2>
<p>Primero hay que separar exactitud y <span class="glossary-term-source" data-glossary-key="calibration">calibración</span>. La exactitud es qué porcentaje aciertas; la calibración es si sabes qué porcentaje vas a acertar. Si reúnes solo los días en los que se anunció un 70% de probabilidad de lluvia y resulta que de cada diez llovió siete veces, esa previsión está bien calibrada. No significa que su exactitud sea alta. Significa que conoce sus propios límites. <strong>Un modelo que solo acierta el 60% saca la nota máxima en calibración si dice de sí mismo que acierta el 60%.</strong></p>
<p>La escala puede desviarse en dos direcciones. Si la probabilidad que declara el modelo es mayor que su tasa real de acierto, se llama overconfidence; si es menor, underconfidence. Si pones la probabilidad declarada en el eje horizontal y la tasa real de acierto de cada tramo de probabilidad en el eje vertical, se ve de un vistazo dónde se separan las dos. Un gráfico dibujado así se llama 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="El eje horizontal es confidence, la probabilidad que declara el modelo, y el eje vertical es accuracy, la tasa real de acierto. La diagonal punteada es perfect calibration; la curva naranja que cae por debajo de ella es overconfidence, y la zona por encima de la diagonal es underconfidence" style="max-width:min(720px, 100%);height:auto;" width="1440" height="1200" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>Si la curva cae sobre la diagonal, el modelo acertó tanto como dijo. Cuanto más cae por debajo de la diagonal, menos acierta en relación con la probabilidad que declara.</p>
<p>La métrica que mide ese desajuste es el <span class="glossary-term-source" data-glossary-key="ece">ECE</span> (expected calibration error). Según la definición de <a href="https://arxiv.org/abs/1706.04599" target="_blank" rel="noopener noreferrer">Guo et al. 2017</a>, es la media, ponderada por la proporción de muestras de cada tramo, de la diferencia entre «la probabilidad declarada» y «la tasa real de acierto» en cada tramo de probabilidad, y 0 es la perfección. El código de abajo transcribe esa fórmula. Compara un modelo que acierta 6 de 10 preguntas cuando dice 0.6 cada vez y cuando dice 0.95 cada vez.</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>Este es el resultado de ejecutarlo con Node v24.16.0 el 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>La exactitud es 0.6 en ambos casos. Solo cambió la probabilidad declarada, y aun así el ECE sube de 0 a 0.35. Pero como la fórmula toma un valor absoluto, el ECE mide solo el tamaño del desajuste, no su dirección. Un modelo que dice 0.25 cada vez y acierta igualmente 6 preguntas también obtiene un ECE de exactamente 0.35. Para saber si es overconfidence o underconfidence hay que mirar el reliability diagram o la confianza media menos la exactitud.</p>
<h2 id="la-función-objetivo-de-rlhf"><a class="anchor" href="#la-función-objetivo-de-rlhf">La función objetivo de RLHF</a></h2>
<p>Entonces, ¿por qué se desajusta esta escala en un modelo pulido con retroalimentación humana? Primero hay que ver qué optimiza <span class="glossary-term-source" data-glossary-key="rlhf">RLHF</span> (reinforcement learning from human feedback). El esqueleto de construir un modelo de recompensa a partir de comparaciones de preferencias humanas salió de <a href="https://arxiv.org/abs/1706.03741" target="_blank" rel="noopener noreferrer">Christiano et al. 2017</a>; uno de los primeros trabajos que lo llevó a modelos de lenguaje fue <a href="https://arxiv.org/abs/1909.08593" target="_blank" rel="noopener noreferrer">Ziegler et al. 2019</a>, e <a href="https://arxiv.org/abs/2203.02155" target="_blank" rel="noopener noreferrer">InstructGPT</a> lo aplicó al seguimiento de instrucciones. Lo que se empuja hacia arriba es <strong>la puntuación de un modelo de recompensa que imita la preferencia humana</strong>. Los trabajos con modelos de lenguaje suman a la recompensa una KL penalty que impide que el modelo se aleje demasiado del que había antes de empezar el aprendizaje por refuerzo, e InstructGPT además mezcló gradientes de los datos de preentrenamiento (PPO-ptx), pero lo que empuja hacia arriba sigue siendo esa puntuación.</p>
<p>Para un chatbot, la preferencia humana es el objetivo correcto. El problema es que esa preferencia se inclina a evitar el tono que revela incertidumbre. <a href="https://arxiv.org/abs/2401.06730" target="_blank" rel="noopener noreferrer">Zhou et al. 2024</a> mostraron, en cuatro conjuntos públicos de datos de preferencia, que los anotadores eligen menos las respuestas con atenuadores como "I'm not sure, maybe". La diferencia era pequeña pero significativa. Eso sí, no eligieron más las respuestas con expresiones que refuerzan la certeza. <a href="https://arxiv.org/abs/2410.09724" target="_blank" rel="noopener noreferrer">Leng et al. 2025</a> mostraron que los modelos de recompensa dan más puntuación a las respuestas que declaran una cifra de confianza alta, sin importar su calidad real. Ambos resultados se refieren a expresiones de confianza dentro del texto (verbalized confidence), no a probabilidades de tokens. TypeSafe, que propone un método de entrenamiento para sustituir a RLHF, también señala en su propia <a href="https://docs.typesafe.ai/introduction/machine-learning-primer" target="_blank" rel="noopener noreferrer">guía introductoria</a> que RLHF puede premiar alucinaciones que suenan seguras (confident-sounding hallucinations). Es la postura de quien vende la alternativa.</p>
<h2 id="las-curvas-de-calibración-de-gpt-4"><a class="anchor" href="#las-curvas-de-calibración-de-gpt-4">Las curvas de calibración de GPT-4</a></h2>
<p>En las probabilidades de los tokens también se ha observado un desajuste en la misma dirección. La Figure 8 del cuerpo del <a href="https://arxiv.org/pdf/2303.08774v6#page=12" target="_blank" rel="noopener noreferrer">informe técnico de GPT-4</a> coloca lado a lado los reliability diagrams del modelo preentrenado y del modelo post-training, y su leyenda dice así:</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>El eje horizontal es la probabilidad (logprob) que el modelo asignó a cada una de las opciones A, B, C y D en preguntas de opción múltiple de MMLU, y el eje vertical es la tasa real de acierto en ese tramo. Según las cifras impresas en la Figure 8, el ECE del modelo preentrenado es <strong>0.007</strong> y el del modelo que pasó por PPO (el algoritmo de aprendizaje por refuerzo que se usa en RLHF para actualizar el modelo hacia puntuaciones más altas del modelo de recompensa) es <strong>0.074</strong>. Empeoró más de diez veces.</p>
<p>En el panel derecho (PPO) de la Figure 8, las barras de 0.4 en adelante quedan por debajo de la diagonal, pero los tramos entre 0.1 y 0.3 quedan, al contrario, por encima. Las barras entre 0.1 y 0.9 se amontonan planas en una tasa real de acierto de aproximadamente 0.3 a 0.5, así que aunque suba la probabilidad que declara el modelo, la tasa de acierto apenas la sigue. En el 0.074 también se mezclan esos tramos de underconfidence. Y, como señalan Guo et al., un reliability diagram no muestra cuántas muestras caen en cada tramo, de modo que no se puede suponer que la distancia de una barra a la diagonal sea proporcional a lo que ese tramo aporta al ECE.</p>
<p>Este panel se diferencia del diagrama conceptual de arriba en dos cosas. Primero, no se limita a caer por debajo de la diagonal como la curva del diagrama conceptual, sino que la cruza una vez. Segundo, mete en los tramos las probabilidades de las cuatro opciones, así que también se aparta de la definición del código de arriba, que mide la probabilidad de la única respuesta elegida. Por eso no comparo el 0.074 con el 0.35 del código de arriba en la misma escala.</p>
<p>Entonces, ¿por qué empeoró? El cuerpo del informe solo dice que el modelo preentrenado estaba bien calibrado y que la calibración se redujo tras el post-training; no da una causa. Una pista está en <a href="https://arxiv.org/abs/2207.05221" target="_blank" rel="noopener noreferrer">Kadavath et al. 2022</a>, de Anthropic. Las políticas RLHF que entrenaron a partir de sus propios modelos de lenguaje parecían, a simple vista, muy mal calibradas. El artículo dijo que no era sorprendente, porque el RL fine-tuning tiende a concentrar las predicciones en los comportamientos que reciben más recompensa. Fue un experimento breve añadido al margen, no el tema central. Sin embargo, al aplicar una sola temperatura, T=2.5, a todas las evaluaciones, los problemas de calibración quedaron en gran parte resueltos en tres evaluaciones. La temperatura es un valor que divide los logits; si es mayor que 1, deja intacto el orden de las respuestas y solo aplana la distribución de probabilidad. Yo leo el hecho de que un solo valor lo resolviera en gran parte como señal de que el desajuste no estaba concentrado en unos pocos tramos, sino que era toda la distribución estrechada hacia un lado. El artículo añadió además la salvedad de que un entrenamiento RL más intenso podría distorsionar la calibración de maneras que no se arreglan así.</p>
<p>Este es un resultado sobre modelos de Anthropic, así que no se puede trasladar directamente como causa de la Figure 8. Los estudios de la sección anterior también trataban expresiones de confianza dentro del texto. No encontré una fuente primaria que conecte directamente el resultado de que las personas evitan el tono inseguro con el desajuste en las probabilidades de los tokens.</p>
<h2 id="la-probabilidad-que-recibe-tu-código"><a class="anchor" href="#la-probabilidad-que-recibe-tu-código">La probabilidad que recibe tu código</a></h2>
<p>La probabilidad de la Figure 8 es la probabilidad logarítmica de un token. Para recibir un valor del mismo tipo, según la <a href="https://github.com/openai/openai-openapi/blob/506aff0a8099581b50e119b87f8f2692cdad043f/openapi.yaml" target="_blank" rel="noopener noreferrer">especificación de la API de OpenAI</a>, pones <code>logprobs</code> en <code>true</code> en una petición de Chat Completions y con <code>top_logprobs</code> eliges, entre 0 y 20, cuántos candidatos recibir en cada posición de token. El valor que obtienes pidiendo al modelo que diga un número de confianza junto con su respuesta es confianza expresada con palabras, y es un valor distinto de la probabilidad del token.</p>
<p>Los estudios sobre estos dos valores comparan pares distintos. <a href="https://arxiv.org/abs/2305.14975" target="_blank" rel="noopener noreferrer">Tian et al. 2023</a> informaron de que, dentro de modelos RLHF como ChatGPT, GPT-4 y Claude, la confianza expresada con palabras solía estar mejor calibrada que las probabilidades condicionales, y que en tres benchmarks a menudo reducía el ECE en torno a un 50% relativo. Eso sí, en esta comparación la probabilidad condicional de los modelos cuyos pesos no son públicos no era la probabilidad de tokens de la API, sino una estimación: la proporción de veces que salió la respuesta al muestrear 10 veces la misma pregunta. Leng et al., que vimos antes, informaron de que, frente a los modelos anteriores a RLHF, los modelos RLHF muestran más overconfidence en la confianza que expresan con palabras. Ambos resultados pueden ser ciertos a la vez. Y 0.074 es el valor que obtuvo el modelo post-training de GPT-4 de 2023 en un subconjunto de MMLU, así que no se puede trasladar tal cual a los modelos que llamas hoy por la API.</p>
<h2 id="para-cerrar"><a class="anchor" href="#para-cerrar">Para cerrar</a></h2>
<p>En resumen, la calibración no trata de qué porcentaje aciertas, sino de si sabes qué porcentaje vas a acertar, y el ECE es la cifra que mide el tamaño de ese desajuste. Para ver la dirección hay que mirar el reliability diagram o la confianza media menos la exactitud. RLHF empuja hacia arriba la puntuación de un modelo de recompensa que imita la preferencia humana. Kadavath et al. consideran que esa optimización tiende a estrechar las predicciones hacia lo que más recompensa recibe. Hay estudios que muestran que las personas eligen menos las respuestas que revelan incertidumbre y que los modelos de recompensa dan más puntuación a las respuestas que declaran una cifra de confianza alta, sin importar la calidad. En GPT-4, el ECE quedó registrado pasando de 0.007 a 0.074 tras el post-training, pero OpenAI no llegó a escribir la causa. Como la calibración también sale distinta según si la probabilidad que recibe tu código es una probabilidad de tokens o una confianza expresada con palabras, sea cual sea, tienes que volver a medirla con tus propios datos.</p>
<p>Si las probabilidades de Jev, un modelo de decisión que presenta la calibración como objetivo de entrenamiento, son de verdad honestas se trata en <a href="/260922" target="_blank" rel="noopener noreferrer">Modelos de decisión, Jev y Kev</a>.</p>
<p>Ojalá que tú también, antes de fijar en el código como umbral la probabilidad que devuelve un modelo, compruebes al menos una vez para qué se entrenó ese modelo y si su escala se sostiene con tus propios datos.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>IA</category>
            <category>Calibración</category>
        </item>
        <item>
            <title><![CDATA[Lenguaje compartido]]></title>
            <link>https://hooninedev.com/es/260723</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260723</guid>
            <pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de la comunicación con otras disciplinas. Mientras desarrollo, dedico tanto tiempo a averiguar qué hay que construir como a escribir código. Intercambio requisitos con e...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de la comunicación con otras disciplinas.</p>
<p>Mientras desarrollo, dedico tanto tiempo a averiguar qué hay que construir como a escribir código. Intercambio requisitos con el product manager, ajusto pantallas con el diseñador y pregunto a quien conoce el dominio qué significa cada término. Y he comprobado varias veces que el coste de que ese proceso falle es mucho mayor que el coste de escribir mal el código.</p>
<p>Hace poco leí <a href="https://softwareleads.substack.com/p/engineering-leaders-day-to-day-activities" target="_blank" rel="noopener noreferrer">Las actividades diarias de un líder de ingeniería</a>, escrito por James Samuel. El autor divide el trabajo de un líder en seis apartados y el primero que aborda es la recopilación de información. Su razón es que toda decisión, toda dirección y toda acción dependen de una comprensión precisa de lo que está ocurriendo ahora mismo.</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="Las actividades directivas dibujadas como seis círculos enlazados: recoger información, planificar, liderar, influir en decisiones, decidir y compartir información" width="1951" height="1673" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>Aunque quiero asumir un rol de liderazgo algún día, lo que pensé al leer ese pasaje fue algo distinto. <strong>Si lo primero que aborda un líder es recopilar información, el equivalente para quien está en la ejecución es entender los requisitos.</strong> La forma en que manejo hoy los requisitos será la forma en que maneje mañana la información de una organización.</p>
<p>No pretendo hablar de liderazgo en este artículo. Hablo de qué estaba haciendo mal al comunicarme con otras disciplinas desde mi puesto actual y de qué puedo usar como herramienta. Empezando por la conclusión: llegué a pensar que este problema no es solo una cuestión de actitud. Existe una formulación alternativa, llamada ubiquitous language, y vale la pena ver de qué se trata.</p>
<h2 id="la-misma-palabra-significados-distintos"><a class="anchor" href="#la-misma-palabra-significados-distintos">La misma palabra, significados distintos</a></h2>
<p>En su artículo sobre <a href="https://martinfowler.com/bliki/BoundedContext.html" target="_blank" rel="noopener noreferrer">Bounded Context</a>, Martin Fowler pone el ejemplo de una compañía eléctrica. Buena parte de los desajustes que he vivido tenían esta forma.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Aquí la palabra «meter» significaba cosas sutilmente distintas en diferentes partes de la organización: ¿era la conexión entre la red y una ubicación, entre la red y un cliente, o el contador físico en sí?</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>La misma palabra, «meter», significaba cosas sutilmente distintas según el departamento de la organización. Y Fowler añade que esa confusión no es una casualidad de ese caso concreto: dice haber visto repetirse la misma confusión con palabras polisémicas como Customer o Product.</p>
<p><strong>Esto no ocurrió porque los asistentes a la reunión fueran malos en las reuniones.</strong> Cuando una organización contiene contextos distintos, es natural que la misma palabra se bifurque, y si se deja pasar, acaba bifurcando también el código.</p>
<p>El trasfondo técnico de esta historia lo traté una vez en <a href="/260418" target="_blank" rel="noopener noreferrer">Modelo de dominio</a>. Allí hablaba de cómo expresar el modelo dentro del código. En este artículo trato la etapa anterior a que ese modelo exista, la de la conversación entre personas.</p>
<h2 id="una-palabra-con-dos-significados"><a class="anchor" href="#una-palabra-con-dos-significados">Una palabra con dos significados</a></h2>
<p>Pero ¿por qué se repite esto de forma estructural? Hay dos líneas de explicación.</p>
<p>Una viene de la estructura organizativa. El artículo <a href="https://melconway.com/Home/Committees_Paper.html" target="_blank" rel="noopener noreferrer">How Do Committees Invent?</a>, que Mel Conway publicó en Datamation en 1968, sostiene que, en la medida en que una organización no puede cambiar con flexibilidad su estructura de comunicación, esa organización estampa su propia imagen en todos los diseños que produce. La única frase que suele citarse como ley de Conway es en realidad una formulación que el propio autor elaboró más tarde, y esta descripción del texto de 1968 me resulta más directa.</p>
<p>Lo que leí en clave práctica en esa frase es la expresión «estampa». Significa que la bifurcación de los términos no termina en la conversación, sino que <strong>permanece en los artefactos</strong>. El «meter» anterior es exactamente eso. Si cada uno construye manteniendo un entendimiento distinto del alcance de «meter», esa diferencia no queda en el acta de la reunión, sino en los nombres de las tablas, en los campos de la respuesta de la API y en los textos de la pantalla. Y a partir de ahí, el coste de arreglarlo no admite comparación con el de mantener una conversación más.</p>
<p>Aquí se separa lo que quien está en la ejecución puede hacer de lo que no. Cambiar la estructura de comunicación de la organización queda fuera de su alcance. Pero <strong>hacer visibles los límites de los términos</strong> sí se puede hacer desde el puesto actual. Consiste en anotar qué alcance tiene una palabra en este documento y en indicar explícitamente cuándo difiere de la palabra que usa otro equipo. Todas las herramientas que veremos después son dispositivos que facilitan ese trabajo.</p>
<p>La otra explicación es algo más de fondo. Desde la ciencia cognitiva, la colaboración solo se sostiene sobre un trasfondo compartido. <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>, escrito por Herbert Clark y Susan Brennan en 1991, arranca con el ejemplo de un dúo de piano. Los dos intérpretes no pueden ni empezar a coordinarse sin presuponer una cantidad enorme de información compartida, es decir, common ground. Aquí common ground significa conocimiento mutuo, creencias mutuas y supuestos mutuos. Y los autores afirman con rotundidad que toda acción colectiva se construye sobre el common ground y su acumulación.</p>
<p>La perspectiva a la que llegué tras poner ambas líneas una junto a otra es esta. <strong>Que los términos se desajusten no es un accidente, es el estado por defecto.</strong> Estar alineados es la excepción, el estado que hay que mantener pagando un coste. Entonces la pregunta práctica pasa a ser quién paga ese coste y cómo.</p>
<h2 id="vigilancia-activa"><a class="anchor" href="#vigilancia-activa">Vigilancia activa</a></h2>
<p>En Domain-Driven Design, publicado en 2003, Evans presenta un patrón llamado ubiquitous language, o <strong>lenguaje compartido</strong>. Suele resumirse como poco más que «que developers y expertos de dominio usen los mismos términos», pero al leer el libro original la receta es mucho más concreta. Pide que el equipo tome el modelo como columna vertebral del lenguaje y se comprometa a usar ese lenguaje con insistencia en toda la comunicación del equipo y en el código. Y la frase que viene a continuación fue la más importante para mí.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Los expertos de dominio objetan a los términos o estructuras que resultan torpes o inadecuados para transmitir el entendimiento del dominio, mientras que los developers vigilan la ambigüedad o la inconsistencia que hará tropezar al diseño.</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>Los expertos de dominio objetan a los términos y estructuras torpes o insuficientes para transmitir el entendimiento del dominio, y <strong>los developers vigilan la ambigüedad o inconsistencia que derribará el diseño</strong>.</p>
<p>Al leer esa frase corregí mi comprensión de mi propio papel. Hasta entonces me consideraba un receptor en las reuniones de requisitos. Pensaba que mi trabajo era recibir con exactitud lo que se decidiera en producto e implementarlo. Pero la parte que Evans asigna al developer no era la recepción. <strong>Es detectar la ambigüedad y devolverla.</strong> Eso no es cooperación pasiva, es vigilancia activa.</p>
<p>(Por cierto, Fowler tradujo esta frase en su artículo como «Domain experts <strong>should</strong> object ... developers <strong>should</strong> watch». El libro original no lleva verbo modal. Es una diferencia menor, pero el original se lee de forma más categórica: el tono no es que convenga hacerlo, sino que hacerlo es la definición del patrón.)</p>
<p>¿Y cómo se detecta la ambigüedad? Evans también responde a eso: usar los términos repetidamente en la conversación es lo que revela las diferencias de interpretación. Yo leí esa frase así. La ambigüedad no se encuentra leyendo documentos con lupa. Por muy cuidadosamente que se lea una especificación, no aflora el hecho de que «meter» tiene varios significados. Esa palabra tiene que usarse repetidamente contra casos concretos para que se bifurque. El momento en que surge una pregunta como «¿Qué entienden los demás por meter?» es justamente ese punto.</p>
<h2 id="el-principio-del-mínimo-esfuerzo-colaborativo"><a class="anchor" href="#el-principio-del-mínimo-esfuerzo-colaborativo">El principio del mínimo esfuerzo colaborativo</a></h2>
<p>Puede que conozcamos este método y aun así no lo practiquemos. Entonces, ¿por qué no preguntamos una y otra vez?</p>
<p>Volvamos al artículo de Clark y Brennan. Los autores dan el nombre de grounding al proceso de convertir lo dicho en parte del common ground. Y proponen un principio sobre cómo se comporta la gente durante ese proceso: el principio del mínimo esfuerzo colaborativo (the principle of least collaborative effort). Nace de la observación de que a la gente no le gusta trabajar más de lo necesario.</p>
<p>Aquí aparece el pasaje importante.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Según el principio del mínimo esfuerzo colaborativo, la gente debería intentar hacer grounding con el menor esfuerzo conjunto necesario. Pero qué es lo que cuesta esfuerzo cambia drásticamente según el medio de comunicación.</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>El punto donde recae el esfuerzo cambia drásticamente según el medio.</strong> Una técnica de confirmación disponible en un medio puede ser directamente imposible en otro o, aun siendo posible, mucho más costosa. Los autores señalan en particular que, en los medios donde el interlocutor no recibe lo dicho de inmediato, el coste de confiar en que otro corrija el malentendido se vuelve muy alto, así que el hablante tiende a evitarlo.</p>
<p>Conviene precisar algo aquí. Este artículo se escribió en 1991 y los medios que compararon los autores son la conversación cara a cara, el teléfono, las cartas o el contestador automático. <strong>No trata Slack ni Notion.</strong> El marco de que el coste del grounding varía según el medio es de los autores; aplicarlo a los canales de trabajo asíncronos de hoy es interpretación mía.</p>
<p>Aplicado así, se explica por qué yo no preguntaba una y otra vez. En un entorno donde recibes la especificación como texto y la confirmas como texto, enviar un mensaje de confirmación en cada punto ambiguo es caro. No sabes cuándo llegará la respuesta y temes que preguntar varias veces te haga parecer alguien que no ha entendido. Así que uno se desliza de forma natural hacia su propia interpretación.</p>
<p>Conviene distinguir aquí dos fuerzas. Una es que el medio encarece la confirmación, y eso es lo que dicen Clark y Brennan. La otra es el temor a parecer incompetente por preguntar varias veces, y eso no es lo que predice su principio, sino que pertenece a la seguridad psicológica de la que hablaremos después. Con precisión: lo que predice el principio del mínimo esfuerzo colaborativo es que <strong>se elegirá una forma más barata de confirmar</strong>, no que se dejará de confirmar. Renunciar a confirmar y pasar a la suposición no es el principio operando, es el grounding fracasando. Y el hecho de que esa interpretación fuera errónea aflora cuando la implementación ya está terminada.</p>
<h2 id="alcanzar-un-nivel-suficiente"><a class="anchor" href="#alcanzar-un-nivel-suficiente">Alcanzar un nivel suficiente</a></h2>
<p>Entonces, ¿hay que preguntar por cada ambigüedad? No es realista y nunca he visto a nadie hacerlo. El artículo también ofrece un criterio para esta pregunta: el grounding criterion. Designa el estado en que ambas partes creen mutuamente que el oyente ha entendido lo que quería decir el hablante <strong>hasta un nivel suficiente para el propósito actual</strong>. Los autores lo preceden con la observación de que la comprensión perfecta es imposible de entrada.</p>
<p>La razón por la que este criterio me resultó práctico es que rebaja el objetivo. No hace falta entender los requisitos a la perfección. <strong>Basta con poder confiar mutuamente en que se entienden lo suficiente para lo que se va a hacer.</strong> Y los autores dicen que, si cambia el propósito, el criterio debe cambiar con él.</p>
<p>En la práctica, esto se convierte en el criterio para calibrar la intensidad de la confirmación. Si en una sesión que aún explora la dirección te enrocas en «¿Qué significa meter aquí?», la conversación no avanza. Lo que hace falta en esa etapa es acordar qué se quiere hacer y por qué, no fijar los valores límite. Al contrario, justo antes de entrar en implementación esa pregunta tiene que salir sí o sí. Ahí hay que poder confiar en que ambos están imaginando el mismo alcance para «meter»; si no, el código se apila sobre una premisa equivocada.</p>
<p>Por eso, ante la misma ambigüedad, <strong>la trato de forma distinta según la etapa en la que estoy.</strong> En la etapa de exploración la anoto en una lista y sigo; justo antes de implementar abro esa lista y voy cerrando los puntos uno a uno. Aplazar la confirmación y renunciar a ella son cosas distintas.</p>
<p>Hay una herramienta que combina bien en este punto. Team Topologies, de Matthew Skelton y Manuel Pais, distingue tres modos de interacción entre equipos: collaboration, cuando durante un periodo definido se descubre algo nuevo en conjunto; X-as-a-Service, cuando una parte provee y otra consume; y facilitation, cuando una parte ayuda y mentoriza a la otra.</p>
<p>En origen es una discusión sobre diseño organizativo, pero me pareció práctico reducirlo a la escala de una sola reunión. <strong>Si cada parte cree que esta conversación está en un modo distinto, la reunión se descarrila.</strong> Si el product manager cree que es una sesión para comunicar lo ya decidido mientras el developer cree que es una sesión para descubrir juntos, las preguntas del developer suenan a objeción y no a colaboración. También ocurre al revés. Si el developer vino a recibir una especificación cerrada y el product manager todavía estaba explorando, el developer se frustra porque no hay especificación.</p>
<p>Por eso últimamente confirmo esto al principio de la reunión: «¿Esto sigue abierto o es una sesión para confirmar lo que ya está decidido?». Esa sola pregunta cambia el carácter de todo lo que viene después. Es lo mismo que ajustar el grounding criterion al propósito.</p>
<h2 id="cuando-la-resolución-no-coincide-la-conversación-gira-en-vacío"><a class="anchor" href="#cuando-la-resolución-no-coincide-la-conversación-gira-en-vacío">Cuando la resolución no coincide, la conversación gira en vacío</a></h2>
<p>Aun con el modo alineado, queda un problema. Otro desajuste que he vivido a menudo es hablar con <strong>resoluciones</strong> distintas.</p>
<p>Los requisitos que el product manager entrega en prosa suelen ser demasiado abstractos. De una frase como «que el usuario pueda consultar cómodamente su historial de reservas» no se deduce cuántas pantallas hay ni qué lleva a dónde. Al contrario, la maqueta del diseñador es demasiado concreta. El color de los botones y los márgenes ya están decididos, así que resulta difícil discutir precisamente si este flujo es el correcto.</p>
<p>Shape Up, escrito por Ryan Singer en Basecamp, señala este problema con precisión: empezar por wireframes o por maquetas visuales concretas te encierra en detalles innecesarios e impide explorar tan ampliamente como haga falta. Por eso, <a href="https://basecamp.com/shapeup/1.3-chapter-04" target="_blank" rel="noopener noreferrer">lo que propone Shape Up</a> es una representación intermedia. Se llama breadboarding y toma prestado el concepto de la ingeniería eléctrica. Una breadboard es un prototipo que tiene todas las piezas y el cableado del aparato real, pero ningún diseño industrial. Por eso solo se dibujan tres cosas: los lugares a los que se puede navegar (places), aquello que el usuario puede accionar (affordances) y las líneas de conexión que muestran a dónde lleva esa acción al usuario (connection lines).</p>
<p>La razón por la que valoro esta técnica es que es un artefacto que puede producir el developer. En lugar de pedirle al product manager que escriba con más detalle o de esperar la maqueta del diseñador, puedes dibujar allí mismo el flujo tal como lo has entendido y devolverlo con un «lo he entendido así, ¿es correcto?». Es una forma concreta de ejecutar la vigilancia de la que hablaba Evans, la de devolver la ambigüedad. Y visto desde la sección anterior, es un dispositivo que abarata el grounding. Un dibujo sustituye varias rondas de confirmación por texto.</p>
<p>Pero esta resolución cuesta más de sostener de lo que parece. Mientras dibujas el flujo, acabas bajando de forma natural a cosas como «¿no sería mejor poner este botón abajo a la derecha?». A mí me ha pasado varias veces. Una vez que bajas así, la pregunta que se estaba discutiendo, es decir, si este flujo es el correcto, desaparece en silencio. Si decides la posición de los botones cuando el flujo todavía no está cerrado, toda esa discusión se tira por completo cuando el flujo cambie después.</p>
<p>Por eso en esta etapa intento mantener <strong>un dibujo deliberadamente malo</strong>. Si solo hay rectángulos, flechas y nombres, la otra persona ni se plantea señalar detalles y responde únicamente al flujo. Que el dibujo tenga poco acabado es, en esta herramienta, una función. (Contener las ganas de dibujarlo bonito cuesta más de lo que parece)</p>
<p>Que el propio nivel de abstracción pueda ser una herramienta de colaboración enlaza con lo que traté en <a href="/260201" target="_blank" rel="noopener noreferrer">Abstracción</a>. Allí hablaba del nivel de abstracción del código; en la conversación existe lo mismo.</p>
<h2 id="separar-reglas-ejemplos-y-preguntas"><a class="anchor" href="#separar-reglas-ejemplos-y-preguntas">Separar reglas, ejemplos y preguntas</a></h2>
<p>Otra manera de igualar la resolución es estructurar la propia discusión.</p>
<p>Veamos <a href="https://cucumber.io/blog/bdd/example-mapping-introduction/" target="_blank" rel="noopener noreferrer">Example Mapping</a>, presentado en 2015 por Matt Wynne, que lideró el proyecto Cucumber. Él diagnostica que la razón por la que a muchos equipos les cuesta discutir requisitos es que, al no haber estructura, la discusión se alarga y se vuelve aburrida. Y por eso dejan de hacerla con regularidad y con coherencia. A mí me pasaba exactamente eso: cuando una reunión de requisitos se alarga, la siguiente intentas acabarla rápido, y si la acabas rápido las ambigüedades se quedan tal cual.</p>
<p>Example Mapping divide la discusión en tarjetas de cuatro colores. La amarilla es la historia que se está tratando, la azul es la regla o criterio de aceptación, la verde es el ejemplo concreto que ilustra esa regla y la roja es la pregunta cuya respuesta nadie conoce.</p>
<p>De todas ellas, <strong>creo que la tarjeta roja es la clave.</strong> Las otras tres son técnicas de ordenación, pero la roja es el dispositivo que convierte el «no lo sé» en un producto oficial de la reunión. Decir que no se sabe deja de ser un acto que retrasa la reunión y pasa a ser un resultado que la reunión debe producir.</p>
<p>Por qué esto importa se ve claro al imaginar el caso contrario. En una reunión sin tarjetas rojas, encontrarse con un punto ambiguo deja dos opciones: enrocarse ahora y alargar la reunión, o seguir adelante e interpretarlo uno mismo más tarde. Como vimos en la sección anterior, la gente suele elegir lo segundo. Pero con una tarjeta a mano aparece una tercera opción. <strong>Anotarlo y seguir, sin que desaparezca.</strong> La reunión continúa y la ambigüedad permanece en la lista.</p>
<p>La tarjeta verde cumple un papel parecido. Cuando solo se escribe la regla, todo el mundo asiente; en el momento en que alguien intenta escribir un ejemplo concreto que la ilustre, las interpretaciones se separan. Es el «usarlos repetidamente en la conversación revela las diferencias» de Evans trasladado al formato de reunión. Si pides que se añadan tres ejemplos por cada regla, en uno de ellos suele salir un «espera, ¿y en este caso qué pasa?». (Por mi experiencia, la reunión en la que esa pregunta no aparece no es una reunión en la que todos entendieron, sino una en la que cada uno entendió algo distinto)</p>
<p>Con una idea parecida existe también Three Amigos, usado desde hace tiempo en el ámbito de BDD. Consiste en que las tres perspectivas de negocio, desarrollo y pruebas plantean cada una una pregunta distinta, y el <a href="https://www.agilealliance.org/glossary/three-amigos/" target="_blank" rel="noopener noreferrer">glosario de Agile Alliance</a> resume esas preguntas como qué problema se intenta resolver, cómo se construirá una solución para resolverlo y qué pasa con esto, qué podría llegar a ocurrir. Qué le sucede a una reunión donde la tercera pregunta nunca aparece probablemente no haga falta explicarlo.</p>
<h2 id="crear-un-proceso-para-comprobar-lo-que-no-se-sabe"><a class="anchor" href="#crear-un-proceso-para-comprobar-lo-que-no-se-sabe">Crear un proceso para comprobar lo que no se sabe</a></h2>
<p>Ahora bien, ¿por qué nos cuesta tanto sacar la tarjeta roja? Aquí no hay manera de esquivar el tema de la seguridad psicológica.</p>
<p>El <a href="https://web.mit.edu/curhan/www/docs/Articles/15341_Readings/Group_Performance/Edmondson%20Psychological%20safety.pdf" target="_blank" rel="noopener noreferrer">artículo</a> que Amy Edmondson publicó en Administrative Science Quarterly en 1999 es el estudio que estableció formalmente el concepto de seguridad psicológica de equipo. (El propio artículo señala como raíz el trabajo de Schein y Bennis de 1965.) Define la seguridad psicológica de equipo como la creencia compartida de que el equipo es un lugar seguro para asumir riesgos interpersonales. Decir que no se sabe, expresar desacuerdo y admitir un error entran ahí. El artículo estudió 51 equipos de una empresa manufacturera y mostró que la seguridad psicológica se asocia con el comportamiento de aprendizaje y que ese comportamiento media entre la seguridad psicológica y el rendimiento del equipo.</p>
<p>Pero la frase que me pareció más práctica de ese artículo no fue la definición, sino la que viene justo después.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>En su mayor parte, esta creencia tiende a ser tácita: se da por sentada y ni los individuos ni el equipo en su conjunto le prestan atención directa.</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>Esta creencia suele ser <strong>tácita</strong> y ni los individuos ni el equipo le prestan atención directa.</p>
<p>A partir de aquí es una conjetura mía. Lo que dice el artículo llega hasta que la creencia es tácita; la receta de que por tanto basta con crear un formulario no está en el artículo. Dicho esto, he visto varias veces que lo tácito no cambia por declaración. Decir «somos un equipo donde se puede preguntar lo que no se sabe» no cambia gran cosa por sí solo. Por eso creo que la tarjeta roja de la sección anterior importa. <strong>Si conviertes el «no lo sé» en una casilla dentro de un formulario, rellenarla pasa a ser procedimiento en lugar de valentía.</strong></p>
<p>Cabe una objeción, por supuesto. En un equipo que no es seguro, la casilla de la tarjeta roja se quedará simplemente vacía. Es una crítica justa y no pretendo afirmar que esta herramienta genere seguridad. Pero si la casilla existe, al menos <strong>el hecho de que esté vacía se vuelve visible.</strong> Aparece un lugar donde preguntar si está vacía porque nadie tiene dudas o porque cuesta decirlas.</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="La plantilla Bounded Context Canvas de la comunidad DDD, con apartados para propósito, clasificación estratégica, lenguaje ubicuo, supuestos, métricas de verificación y preguntas abiertas" width="1680" height="1100" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>La misma idea está incorporada en formatos de documento reales. Si miras el <a href="https://github.com/ddd-crew/bounded-context-canvas" target="_blank" rel="noopener noreferrer">Bounded Context Canvas</a> creado por la comunidad DDD, es una herramienta colaborativa para diseñar y documentar un contexto, y su reparto de casillas resulta interesante. Nombre y propósito, clasificación estratégica, rol de dominio, comunicación entrante y saliente, y luego <strong>Ubiquitous Language</strong>, decisiones de negocio, <strong>Assumptions</strong>, métricas de verificación y <strong>Open Questions</strong>.</p>
<p>Hay una casilla aparte para escribir el lenguaje compartido, otra para los supuestos y otra para las preguntas abiertas. La descripción del canvas dice que el propio acto de escribir el propósito obliga a formular con claridad un pensamiento difuso y coloca a todo el equipo en la misma página.</p>
<p>Creo que este planteamiento es el enfoque más realista para los problemas de comunicación. En vez de intentar cambiar la actitud de las personas, <strong>crear una casilla para aquello que cuesta decir.</strong> Una buena estructura no exige que la gente lo haga bien, sino que se lo pone fácil.</p>
<h2 id="por-qué-esto-no-es-una-cuestión-de-gusto"><a class="anchor" href="#por-qué-esto-no-es-una-cuestión-de-gusto">Por qué esto no es una cuestión de gusto</a></h2>
<p>Llegados aquí, cabe una reacción: está bien, pero al final esto es «comuniquémonos con empeño», ¿no? ¿Y eso no es cuestión de carácter?</p>
<p>Yo también lo pensé durante un tiempo. Pero sobre esta parte hay datos.</p>
<p>Cuando DORA aborda la cultura organizativa, toma la clasificación del sociólogo Ron Westrum: pathological, orientada al poder; bureaucratic, orientada a las reglas; y generative, orientada al resultado. Y <a href="https://dora.dev/capabilities/generative-organizational-culture/" target="_blank" rel="noopener noreferrer">la documentación oficial de DORA</a> resume así sus hallazgos.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Una cultura organizativa de alta confianza que enfatiza el flujo de información es predictiva del rendimiento en la entrega de software.</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>Una cultura organizativa de alta confianza que <strong>enfatiza el flujo de información</strong> predice el rendimiento en la entrega de software. No dice que los equipos que se comunican bien estén de mejor humor, sino que es una variable que se mueve junto al rendimiento.</p>
<p>Ahora bien, «predecir» no debe leerse aquí como causalidad. Los datos de DORA proceden de una encuesta en la que los mismos participantes respondieron sobre cultura y sobre rendimiento, y es una correlación a nivel organizativo. Queda la posibilidad de que quien está satisfecho con su organización puntuara alto en ambas cosas, y de una relación a nivel organizativo no se deriva de inmediato una receta sobre cómo debe comportarse un individuo. Así que hasta aquí llega lo que permite decir este material: el flujo de información no vive solo en el terreno del gusto, está en un lugar donde se mueve junto al rendimiento.</p>
<p>El mismo documento recoge también las tres propiedades de la buena información según Westrum: responde a la pregunta que necesita quien la recibe, llega en el momento oportuno y se presenta de forma que quien la recibe pueda usarla con eficacia. Merece la pena fijarse en que las tres sitúan el criterio en <strong>quien recibe</strong>. Compartir mucho no es lo que hace bueno un flujo de información.</p>
<p>El Project Aristotle de Google es otra fuente citada con frecuencia. Según <a href="https://rework.withgoogle.com/intl/en/guides/understand-team-effectiveness" target="_blank" rel="noopener noreferrer">lo publicado en reWork</a>, estudiaron 180 equipos, aplicaron más de 35 modelos estadísticos a cientos de variables y propusieron cinco factores que influyen en la efectividad de un equipo: seguridad psicológica, fiabilidad, estructura y claridad, significado e impacto. Y la conclusión fue que importaba más cómo trabajaba junto ese equipo que quién estaba en él.</p>
<p>Aun así, quiero señalar una cosa. La página oficial declara que enumera los cinco factores por orden de importancia y coloca la seguridad psicológica en primer lugar. Pero las expresiones de magnitud del tipo «primero de forma abrumadora», que aparecen a menudo en artículos que citan esta investigación, no están en esa página. Que exista un orden y que uno arrase sobre los demás son cosas distintas. Por eso en este artículo no afirmo nada sobre la magnitud. Para argumentar la importancia de la seguridad psicológica, lo preciso es apoyarse en el artículo original de Edmondson de la sección anterior.</p>
<h2 id="de-vuelta-al-tema-del-liderazgo"><a class="anchor" href="#de-vuelta-al-tema-del-liderazgo">De vuelta al tema del liderazgo</a></h2>
<p>Volvamos al artículo citado al principio.</p>
<p>Al abordar en primer lugar la recopilación de información, James Samuel dice que los métodos de la etapa de ejecución dejan de funcionar. Como colaborador individual tienes la imagen completa de tu propio trabajo, pero cuando asumes responsabilidad sobre personas ya no puedes usar el método anterior. Por eso señala como capacidad necesaria la de filtrar el ruido y sintetizar la información en una imagen coherente de la realidad, porque ningún manager puede procesarlo todo.</p>
<p>Sentí que esto es lo mismo que las tres propiedades de la buena información de Westrum que vimos antes. No se trata de recopilar mucho, sino de convertirlo en algo utilizable. Y eso no se diferencia de lo que estoy haciendo ahora al trabajar con requisitos. Extraer las partes ambiguas de las frases de una especificación, redibujarlas como breadboard para confirmarlas y dejar lo que no se sabe como tarjeta roja es exactamente el ejercicio de filtrar el ruido y construir una imagen coherente.</p>
<p>También se me quedó lo que el autor dice sobre la toma de decisiones: esperar a tener certeza es en sí mismo una decisión y conlleva un coste. Esperar a que los requisitos sean del todo claros es igual. Por eso el grounding criterion, suficiente para el propósito actual en vez de comprensión perfecta, se convierte en el criterio práctico.</p>
<h2 id="para-terminar"><a class="anchor" href="#para-terminar">Para terminar</a></h2>
<p>En resumen.</p>
<p>El fallo de comunicación con otras disciplinas no suele ser solo una cuestión de actitud. Cuando una organización contiene contextos distintos, que la misma palabra se bifurque es el estado por defecto, y estar alineados es la excepción que hay que mantener pagando un coste. La forma de pagar ese coste es el lenguaje compartido, y dentro de él la parte del developer no es transcribir los requisitos, sino vigilar la ambigüedad y devolverla.</p>
<p>Pero devolverla exige esfuerzo, y la gente intenta minimizarlo. Así que, en lugar de apoyarme en la voluntad, decidí usar tres cosas: confirmar primero en qué modo está esta conversación, dibujar el flujo en la resolución intermedia entre la prosa y la maqueta y devolverlo, y crear dentro del formato de reunión una casilla para anotar lo que no se sabe. Ninguna de las tres exige corregir la actitud; las tres facilitan hacerlo.</p>
<p>Todavía no soy líder y nada de lo que he escrito aquí lo he verificado desde ese puesto. Con otro tamaño de equipo u otra cultura organizativa habrá cosas que no funcionen. Pero hay algo de lo que creo poder estar seguro ahora. La manera en que maneje la información más adelante no será algo que aprenda de nuevo, sino el hábito con el que hoy manejo los requisitos, crecido a otra escala. Por eso últimamente intento avergonzarme menos que antes de preguntar en una reunión «¿qué quieres decir con eso?».</p>
<p>Si a quien lee esto le viene a la cabeza alguna palabra de una reunión reciente ante la que asintió sin estar realmente seguro, le recomiendo usar esa palabra una vez más la próxima vez. Al usarla repetidamente, aflora.</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>colaboración</category>
            <category>dominio</category>
            <category>DDD</category>
            <category>comunicación</category>
        </item>
        <item>
            <title><![CDATA[Ingeniería de Harness (Sistemas)]]></title>
            <link>https://hooninedev.com/es/260622</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260622</guid>
            <pubDate>Mon, 22 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar sobre prompt engineering, context engineering y lo que podría venir después. Mientras terminaba el artículo anterior sobre cómo ahorrar tokens, hubo una pregunta que no ...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar sobre prompt engineering, context engineering y lo que podría venir después.</p>
<p>Mientras terminaba el artículo anterior sobre <a href="/260611" target="_blank" rel="noopener noreferrer">cómo ahorrar tokens</a>, hubo una pregunta que no dejaba de rondarme. Creo que el centro de gravedad está pasando del trabajo sobre prompts individuales a la técnica de vaciar y seleccionar información —context engineering—. Al cerrar aquel texto, la siguiente pregunta surgió de forma natural: entonces, ¿qué viene después del contexto?</p>
<p><img src="/content/260622/3.webp" alt="Arquitectura de 3 capas para sistemas de IA fiables, compuesta por prompt, context y harness engineering" width="1680" height="920" loading="eager" fetchpriority="high" decoding="async"></p>
<p>Hay que ser prudente al hablar de «la dirección futura». El futuro admite innumerables caminos; por eso, aquí nos centramos en ordenar <strong>el cambio de énfasis que puede leerse en fuentes primarias ya publicadas</strong>. Aunque inevitablemente haya cierta inferencia, espero que el texto se lea desde perspectivas diversas.</p>
<h2 id="del-prompt-al-contexto"><a class="anchor" href="#del-prompt-al-contexto">Del prompt al contexto</a></h2>
<p>Empecemos por aclarar los términos. Durante un tiempo, el gran tema del sector fue el <strong>prompt engineering</strong>. La cuestión era cómo redactar bien una única instrucción para el model: cómo diseñar indicaciones claras, buenos ejemplos y el formato de salida.</p>
<p>Después creció la unidad de trabajo. Cuando se generalizaron los agentes que operan durante decenas de turnos, dejó de importar solo un prompt y pasó a ser crucial cómo componer <strong>todo el contexto que el modelo ve en cada turno</strong> (system prompt + definiciones de herramientas + historial de conversación + resultados de búsqueda + memoria). A esto lo llamamos <strong>context engineering</strong>. Anthropic articuló este marco en septiembre de 2025 con «Effective context engineering for AI agents», y el estudio sobre context rot publicado ese mismo año por el equipo de Chroma (Hong et al.) añadió evidencia cuantitativa. Con 18 modelos, entre ellos GPT-4.1, Claude 4, Gemini 2.5 y Qwen3, mostraron que el rendimiento se degrada de forma desigual a medida que crece la entrada, incluso en tareas tan sencillas como copiar palabras literalmente. La suposición habitual de que un modelo trata igual el token número 100 y el número 10,000 no se sostiene en la práctica. La conclusión no fue «cuanto más largo el contexto, mejor», sino «cómo se coloca la información importa tanto como qué contiene». Esto impulsó el paso de llenar a seleccionar y ayudó a que el término se asentara con rapidez.</p>
<p>Conviene señalar un malentendido habitual. El prompt engineering no fue <strong>sustituido</strong> por el context engineering. Escribir buenos prompts sigue siendo fundamental; el context engineering se parece más a un concepto superior construido sobre esa base. (Que el interés pase de escribir buen código a diseñar buenos sistemas no vuelve innecesario el coding.) La descripción exacta no es «cambiamos una cosa por otra», sino <strong>«se amplió incluyéndola»</strong>.</p>
<p>Volvamos entonces a la pregunta: ¿esa ampliación terminó en el contexto? No parece que sea así.</p>
<h2 id="el-blog-de-ingeniería-de-anthropic"><a class="anchor" href="#el-blog-de-ingeniería-de-anthropic">El blog de ingeniería de Anthropic</a></h2>
<p>Creo que la forma más honesta de intuir una dirección es leer en orden cronológico qué publican quienes realmente están impulsando el campo. Si seguimos el blog de ingeniería de Anthropic después de «Effective context engineering» (septiembre de 2025), los títulos por sí solos ya dibujan hacia dónde siguió moviéndose el centro de gravedad.</p>
<ul>
<li>Octubre de 2025, Equipar agentes con Agent Skills</li>
<li>Noviembre de 2025, Code execution with MCP: agentes más eficientes</li>
<li>Noviembre de 2025, Effective harnesses for long-running agents</li>
<li>Enero de 2026, Demystifying evals for AI agents</li>
<li>Enero de 2026, Diseñar evaluaciones técnicas AI-resistant</li>
<li>Febrero de 2026, Cuantificar el ruido de infraestructura en evaluaciones de agentic coding</li>
<li>Marzo de 2026, Harness design for long-running application development</li>
<li>Abril de 2026, Scaling Managed Agents: separar cerebro y manos</li>
<li>Mayo de 2026, Cómo contener a Claude en todos los productos</li>
</ul>
<p>Visto con algo de distancia, los términos se agrupan en tres ramas: <strong>harness</strong>, <strong>eval</strong> y <strong>containment</strong>. Lo interpreto como un debate que sube un nivel desde cómo llenar y vaciar el contexto hacia cómo diseñar, medir y controlar el sistema de agentes completo. (Naturalmente, existe la limitación de que son las prioridades de una empresa. Aun así, dado el peso que tiene en el ecosistema de coding agents, cuesta reducirlas a los intereses de un único vendor.)</p>
<p>Veamos cada rama.</p>
<h2 id="harness"><a class="anchor" href="#harness">harness</a></h2>
<p>La palabra harness puede resultar poco familiar. Literalmente es un arnés: el equipo que se coloca a un caballo para dirigir su fuerza. En un agente de IA, harness designa <strong>todo el armazón que rodea al model desde fuera y lo pone a trabajar</strong>: qué tools puede usar y en qué orden, cómo recuperarse de un fallo, hasta dónde llegan sus permisos y cuándo se detiene el loop.</p>
<p>Si context engineering pregunta «¿qué le mostramos al model?», el diseño de harness pregunta «¿cómo hacemos que el model se mueva dentro de ese entorno?». Está una capa más afuera. Si el model fuera una persona recién contratada y muy capaz, el contexto serían los materiales de trabajo que recibe, mientras que el harness sería el entorno y el manual de procedimientos. La misma persona puede rendir de forma irregular en un espacio caótico y llegar mucho más lejos, con idénticas capacidades, sobre un proceso bien diseñado.</p>
<p>La importancia se ve con claridad en <a href="https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents" target="_blank" rel="noopener noreferrer">un fracaso al que se enfrentó directamente el equipo de ingeniería de Anthropic</a>. Colocaron su mejor coding model, Opus 4.5, sobre Claude Agent SDK, le dieron solo instrucciones de alto nivel como «crea un clone de claude.ai» y lo ejecutaron durante varias sesiones. Aunque el model era inteligente, no produjo una app de nivel production. Los fallos se repetían de dos maneras. En una, la sesión intentaba terminar todo de una vez, agotaba el contexto a mitad de la implementación y dejaba a la siguiente una función a medio construir. En la otra, una sesión posterior veía que «ya se ha avanzado bastante» y declaraba que había terminado pese al trabajo pendiente. (Es como una tarea larga repartida por turnos entre varias personas, donde cada relevo carece por completo de los recuerdos del anterior.)</p>
<p>La solución no fue hacer más inteligente al model, sino cambiar el armazón. La primera sesión recibió un prompt exclusivo para preparar el entorno (initializer agent), desplegó más de 200 especificaciones funcionales en <code>feature_list.json</code> y creó <code>init.sh</code> para iniciar el servidor de desarrollo, además de un log de progreso (<code>claude-progress.txt</code>). Cada sesión posterior (coding agent) se ocupaba exactamente de una función, dejaba un estado limpio mediante un git commit y notas de progreso, y terminaba. La siguiente leía primero ese progress file y el git log para saber «hasta dónde llegó el turno anterior» y continuar desde allí. Era el mismo model, pero el resultado cambió al colocarlo sobre este armazón. El harness, no el model, decidió el éxito. (Curiosamente, las recetas de Anthropic no son nuevas. Las listas de funciones, los commits pequeños, las notas de progreso y los smoke tests ejecutados siempre son exactamente lo que hace a diario una persona con experiencia en desarrollo. Un buen armazón para un agente se parece, en definitiva, a incrustar buenos hábitos de engineering en el entorno.)</p>
<p>La conexión con el ahorro de tokens también es clara. El aislamiento de subagentes, la reducción de definiciones de tools y el model routing tratados en el artículo anterior parecen técnicas independientes si se observan por separado. Juntas, sin embargo, son partes de <strong>cómo se diseña un único harness</strong>. Decidir a qué model lane se envía cada tarea, qué tools permanecen activas y dónde se aísla la exploración verbose forma parte del diseño de harness. El ahorro se parece más a un efecto secundario de ese diseño.</p>
<p>Sin embargo, el diseño de harness encierra una trampa peculiar: <strong>cuanto mejor diseñado está el armazón, más probable es que envejezca cuando mejora el model.</strong> Un harness es, en esencia, un conjunto de supuestos sobre «lo que el model no puede hacer por sí solo»; en cuanto puede hacerlo, esos supuestos se convierten en carga. Un <a href="https://www.anthropic.com/engineering/managed-agents" target="_blank" rel="noopener noreferrer">caso de Anthropic</a> lo ilustra exactamente. Sonnet 4.5 tendía a apresurarse para terminar cuando se acercaba al límite de contexto —context anxiety—, así que el armazón incorporó un mecanismo de context reset. Al usar el mismo armazón con Opus 4.5, aquel hábito había desaparecido y el reset cuidadosamente añadido se volvió peso muerto. Cada vez que el model se hace un poco más capaz, alguna parte del armazón puede caducar.</p>
<p>De ahí surge una idea adicional: en lugar de perfeccionar un armazón concreto, <strong>diseñar interfaces que permanezcan estables aunque el armazón cambie</strong>. Managed Agents de Anthropic sigue esta dirección, cuyas raíces están, sorprendentemente, en los sistemas operativos. El OS ha resistido durante décadas porque virtualizó el hardware en abstracciones como procesos y archivos, creando por adelantado un recipiente para programas que aún no existían. Una línea de <code>read()</code> funciona igual con un disco de la década de 1970 que con un SSD actual. Aplicado a los agentes, el mismo razonamiento los divide en tres piezas: el <strong>cerebro</strong> que decide (Claude y el harness), las <strong>manos</strong> que actúan (sandbox de ejecución de código y tools) y el <strong>session log</strong> que registra mediante append todo lo ocurrido. Al separarlas, si muere el container, el cerebro puede tratarlo como un error de tool call; y si muere el harness, puede despertar desde el último punto del session log. Como efecto secundario también bajaron el coste y la latencia. Al iniciar containers solo cuando eran realmente necesarios, Anthropic afirma que el time to first token (TTFT) cayó cerca de un 60% en la mediana y, en p95, más de un 90%. (Aquí volvemos a encontrarnos con el artículo sobre tokens. Allí se recomendaba poner delante las partes estáticas para aprovechar prompt caching; decidir «cómo disponer el contexto para mejorar el cache hit rate» es precisamente la tarea del harness que acompaña a este cerebro.)</p>
<h2 id="eval"><a class="anchor" href="#eval">eval</a></h2>
<p>La segunda rama fue la que más me interesó personalmente. Como si se hubieran puesto de acuerdo, los artículos de comienzos de 2026 convergen en la <strong>evaluación (eval)</strong>.</p>
<p>La razón resulta natural. <strong>¿Con qué comprobamos</strong> si el contexto está bien compuesto, el harness bien diseñado o el coste realmente reducido? Cuanto más largas y complejas son las tareas que un agente resuelve de forma autónoma, más difícil es revisar a simple vista cada una y decidir «¿esto funcionó bien?». La base de la confianza acaba desplazándose hacia la medición. Por eso han pasado al primer plano preguntas como «¿cómo diseñar la evaluación de agentes?», «¿cómo eliminar el ruido de la propia evaluación?» o «¿cómo tratar el eval awareness, cuando el model detecta que lo evalúan y cambia su conducta?».</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="Gráfico que contrasta pass@k y pass^k: al aumentar los intentos, pass@k sube hacia el 100% mientras pass^k cae hacia el 0%" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Evaluar agentes es difícil porque no se parece a una pregunta y respuesta de una sola vez. Un agente llama a tools y cambia estados a lo largo de varios turnos, de modo que un error se propaga y acumula. Además, los resultados varían entre ejecuciones aun con la misma entrada. <a href="https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents" target="_blank" rel="noopener noreferrer">Anthropic</a> divide esta no determinación en dos métricas. <strong>pass@k</strong> es la probabilidad de acertar al menos una vez en k intentos, por lo que crece al aumentar los intentos. <strong>pass^k</strong> es la probabilidad de acertar los k intentos, por lo que disminuye. En generación de código que solo necesita funcionar una vez importa pass@1; en un agente de atención al cliente que debe funcionar siempre, pass^k es fundamental. (Con una tasa de éxito per-trial del 75%, la probabilidad de tres éxitos seguidos es 0.75³, cerca del 42%. Así de grande es la distancia entre «suele funcionar» y «funciona siempre».)</p>
<p>¿Con qué se puntúa entonces un intento? El mismo artículo distingue tres tipos de grader. Los <strong>code-based</strong> (tests superados, static analysis y validación de tool calls) son rápidos, baratos y objetivos, pero débiles en tareas abiertas con varias respuestas válidas. Los <strong>model-based</strong> (LLM-as-judge y puntuación mediante rubric) captan matices de calidad, pero son no deterministas y deben calibrarse periódicamente con evaluación humana. Los <strong>human-based</strong> son los más exactos, pero lentos y caros. En la práctica se combinan los tres, preferiblemente con evaluación determinista como base y la del model como apoyo. Hay otra distinción. Un <strong>capability eval</strong> pregunta «¿qué puede lograr este agente?», por lo que comienza con puntuaciones bajas y ofrece una pendiente que subir. Un <strong>regression eval</strong> pregunta «¿sigue haciendo lo que antes hacía?», por lo que debe mantenerse casi en el 100%; una bajada indica que algo se rompió.</p>
<p>Aquí hay una trampa que se pasa por alto con sorprendente frecuencia: <strong>una puntuación baja puede ser culpa de la evaluación, no del agente.</strong> Anthropic cuenta que Opus 4.5 obtuvo inicialmente un 42% en CORE-Bench. Al investigar encontraron evaluaciones rígidas que esperaban «96.124991…» y marcaban «96.12» como incorrecto, especificaciones ambiguas y tareas estocásticas imposibles de reproducir. Tras corregir bugs y repetir con un scaffold de restricciones más flexibles, la puntuación subió al 95%. Por eso insisten en un principio: <strong>no aceptes la puntuación sin más; lee tú mismo el transcript (registro de ejecución).</strong> Si un frontier model realiza 100 intentos y obtiene un 0%, normalmente el problema está roto, no es que el model sea incapaz.</p>
<p>También resulta interesante que, al avanzar la medición, la propia línea de base se mueve deprisa. En SWE-bench Verified —benchmark destacado de coding agents que usa issues reales de GitHub y puntúa si pasan los tests—, los frontier models pasaron de la franja del 30% a más del 80% en 1 año. A esas alturas, todos los problemas fáciles están resueltos, la puntuación toca techo (saturation) y aparece una paradoja: grandes mejoras de capacidad solo se reflejan en pequeñas diferencias. Según se cuenta, una startup de code review desestimó al principio un model nuevo tras mirar solo evaluaciones one-shot; no vio bien la mejora hasta que adoptó evaluaciones de agentes con tareas más largas y complejas. Por eso un eval no se crea una vez y se da por terminado: es un activo vivo que debe reemplazarse continuamente por versiones más difíciles. (Anthropic lo compara con el «Swiss cheese model» de la ingeniería de seguridad. Una loncha con agujeros no basta, pero al superponer evaluación automática, production monitoring y revisión humana de transcripts, el fallo que atraviesa una capa queda atrapado en la siguiente.)</p>
<h2 id="containment"><a class="anchor" href="#containment">containment</a></h2>
<p>La tercera rama tiene un carácter algo distinto. No trata del coste ni del rendimiento, sino de <strong>seguridad y control</strong>.</p>
<p>Cuantas más herramientas maneja un agente y mayor es su autonomía, más crece el alcance de un solo error —su blast radius—. Para un agente capaz de borrar archivos, enviar solicitudes externas y ejecutar acciones privilegiadas en nombre de alguien, «¿dónde detenemos el daño cuando se equivoca?» importa tanto como «¿qué tan bien lo hace?». En este contexto puede leerse el protagonismo que Anthropic dio en mayo de 2026 a su artículo sobre <a href="https://www.anthropic.com/engineering/how-we-contain-claude" target="_blank" rel="noopener noreferrer">containment en todos sus productos</a>. Divide el riesgo de los agentes en tres ramas: <strong>user misuse</strong>, cuando un usuario solicita algo dañino con malicia o descuido; <strong>model misbehavior</strong>, cuando el modelo actúa por sí mismo sin que nadie se lo pida; y <strong>external attacks</strong> que llegan mediante herramientas, archivos o redes. Una observación interesante es que hacer más inteligente al modelo no se limita a reducir el riesgo. Los modelos menos capaces interpretan mal la situación y cometen errores obvios; los más capaces se equivocan menos, pero encuentran mejor rutas inesperadas para sortear restricciones que nadie escribió de forma explícita.</p>
<p>El punto central es el límite de «supervisar cada acción con una persona». Claude Code buscó inicialmente la seguridad pidiendo aprobación para cada escritura, ejecución y acceso a la red, pero la telemetría mostró que los usuarios aceptaban sin más cerca del 93% de las solicitudes. Cuantas más ventanas aparecen, menos atención recibe cada una: surge la <strong>approval fatigue</strong>. Una defensa probabilística basada en clics humanos siempre conservará agujeros. El centro de gravedad pasa de «vigilar lo que hace el agente» a «limitar lo que puede hacer desde el principio». Las defensas se apilan en tres capas: la <strong>capa de entorno</strong>, con sandbox, VM y control de egress; la <strong>capa del model</strong>, con system prompts y classifiers; y la <strong>capa de contenido externo</strong>, con MCP, plugins y resultados de búsqueda. El principio esencial es <strong>instalar primero la capa de entorno, que bloquea de manera determinista</strong>. No porque las defensas del model sean débiles. De hecho, en el benchmark de Gray Swan para prompt injection, la tasa de éxito de un ataque único ronda el 0.1%, un resultado de primera categoría. Pero con 100 intentos adaptativos sube al 5–6%, y una defensa probabilística no puede alcanzar por naturaleza un 100% de aciertos. Por eso se añade al final una frontera dura. (La introducción de un sandbox a nivel de OS redujo las solicitudes de aprobación en un 84%. El mecanismo de seguridad redujo la fricción.)</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="Defensa de containment en 3 capas: las capas del model y del contenido externo (probabilísticas) se apilan sobre la capa de entorno (determinista, última línea de defensa)" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1198" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Aunque parezca alejado del ahorro de tokens, comparte la misma raíz. Ambos preguntan <strong>«¿qué le damos al agente y hasta dónde?»</strong> Anthropic muestra bien la conexión con dos casos. En uno, el red team interno hizo phishing a un empleado para que ejecutara Claude Code con un prompt malicioso. Una instrucción oculta le hizo leer <code>~/.aws/credentials</code> y enviar el contenido fuera mediante POST; de 25 intentos, funcionó 24 veces. Como el usuario había escrito directamente la instrucción, el classifier del model no vio nada sospechoso. Lo impidieron la frontera ambiental que mantenía las credenciales fuera del sandbox y el control de egress, no un model inteligente. El segundo caso es más sutil. Una allowlist de egress permitió correctamente <code>api.anthropic.com</code>, pero un archivo plantado por un atacante usó su propia API key para llamar a la API de carga de archivos de Anthropic, y los datos salieron hacia la cuenta atacante. El sandbox funcionó perfectamente, pero aun así hubo fuga. La lección fue entender la allowlist no como un «filtro de destinos», sino como «permiso para todas las capacidades disponibles en ese dominio». (Anthropic repite este principio: los hypervisors, filtros de syscall y runtimes de containers ya verificados resistieron; <strong>lo que se rompió fueron los componentes que ellos mismos habían construido encima</strong>.) Retirar tools de MCP que no se usan pertenece al mismo contexto: ahorra coste y reduce la attack surface. Mantener un diseño ligero no solo lo hace más barato y preciso, sino también más seguro.</p>
<h2 id="en-resumen"><a class="anchor" href="#en-resumen">En resumen</a></h2>
<p>Las tres ramas se pueden condensar en una frase. La unidad de atención se expande un nivel cada vez: <strong>del prompt (una instrucción), al contexto (qué mostrar en cada turno), y luego al sistema de agentes completo (cómo operarlo, medirlo y contenerlo)</strong>. Harness responde a «¿cómo hacerlo funcionar?», eval a «¿cómo sabemos si funcionó bien?» y containment a «¿cómo lo detenemos si funciona mal?».</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="Expansión en 3 etapas del interés, desde el prompt al contexto y luego al sistema de agentes completo, con las ramas de harness, eval y containment" style="max-width:min(720px, 100%);height:auto;" width="1680" height="1122" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Conviene insistir: estas tres ramas son <strong>un cambio de énfasis que el autor infiere de materiales ya publicados</strong>, no una predicción del tipo «esto será el estándar en la segunda mitad de 2026». Algunas corrientes crecerán y otras se integrarán bajo nombres diferentes. Lo claro es que ni el prompt ni el contexto desaparecerán: permanecerán como partes de un marco mayor. El campo ha avanzado añadiendo una capa sobre las anteriores, no borrando palabras viejas con otras nuevas, y es probable que siga haciéndolo.</p>
<p>Empezamos hablando de costes y llegamos hasta aquí. El artículo anterior recomendaba abrir el libro de cuentas de tokens; este propone dar un paso más. Conviene examinar a la vez <strong>cómo verificamos</strong> el ahorro (eval), <strong>sobre qué armazón puede repetirse</strong> (harness) y <strong>hasta dónde es seguro ese armazón</strong> (containment). Al final, lo que más perdure probablemente no será una técnica de ahorro concreta, sino el hábito de medir y controlar el propio sistema.</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>IA</category>
            <category>Agentes</category>
        </item>
        <item>
            <title><![CDATA[Kalyx]]></title>
            <link>https://hooninedev.com/es/260617</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260617</guid>
            <pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de Kalyx, la biblioteca headless de DatePicker para React que he creado. Este texto es una reescritura de una retrospectiva que escribí en junio de 2026. La idea que enc...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de <strong>Kalyx</strong>, la biblioteca headless de DatePicker para React que he creado.</p>
<p>Este texto es una reescritura de una retrospectiva que escribí en junio de 2026. La idea que encabezaba el artículo original, "siete pickers con una sola API, más pequeños que un único calendario de una biblioteca competidora", resultó, al volver a comprobarla, medio falsa y medio irrelevante como diferenciador. Por eso lo reorganizo en este orden: por qué lo construí, en qué se diferencia de las opciones existentes y cuál es su definición técnica.</p>
<p>Adelanto la conclusión: lo que distingue a Kalyx no es el número de componentes, sino su <strong>modelo de valor</strong>. Las versiones de referencia son <code>@kalyx/react</code> 1.4.7 y <code>@kalyx/core</code> 1.4.8 (MIT, solo React 19), y los fragmentos de código proceden del <a href="https://github.com/jiji-hoon96/kalyx" target="_blank" rel="noopener noreferrer">repositorio de GitHub</a> en <code>main</code> a fecha de 2026-09-16 (<code>0bb302e</code>).</p>
<h2 id="quería-usar-los-selectores-de-fechas-de-forma-declarativa"><a class="anchor" href="#quería-usar-los-selectores-de-fechas-de-forma-declarativa">Quería usar los selectores de fechas de forma declarativa</a></h2>
<p>Tenía dos motivos para construirlo. Quería usar bibliotecas de fechas complejas y difíciles de manejar de una manera más declarativa y sencilla, y quería aprender cómo se construye por dentro una biblioteca así. "Difícil de manejar" es algo vago, así que volví a revisar las definiciones de tipos de cada biblioteca para ver si los puntos donde me atasqué siguen presentes en sus últimas versiones.</p>
<h3 id="una-api-que-activa-modos-con-props"><a class="anchor" href="#una-api-que-activa-modos-con-props">Una API que activa modos con props</a></h3>
<p>react-datepicker activa la selección de hora con <code>showTimeSelect</code>, la de mes con <code>showMonthYearPicker</code>, la de año con <code>showYearPicker</code> y la de rango con <code>selectsRange</code>. Es una estructura en la que un mismo componente se convierte en otra cosa según la combinación de props. El coste se ve en los tipos. En las definiciones de tipos de la 9.1.0, según el valor de <code>selectsRange</code>, la firma de <code>onChange</code> se bifurca.</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>Es una union bien hecha, pero las ramas se multiplican a medida que crecen los modos, y quien la usa tiene que deducir qué combinación está activa solo por los nombres de las props. (Tiene la misma forma que "una abstracción que agrupa mal aumenta el acoplamiento", de lo que hablé en el artículo sobre <a href="/260201" target="_blank" rel="noopener noreferrer">abstracción</a>.) Yo quería que "un campo de entrada, un popover y un calendario" se leyeran en la propia estructura del JSX.</p>
<h3 id="dónde-se-filtran-los-tipos-de-valor-y-la-zona-horaria"><a class="anchor" href="#dónde-se-filtran-los-tipos-de-valor-y-la-zona-horaria">Dónde se filtran los tipos de valor y la zona horaria</a></h3>
<p>react-datepicker y react-day-picker intercambian objetos <code>Date</code> nativos. Es cierto que ambos tienen una prop <code>timeZone</code> que acepta zonas IANA. react-datepicker necesita <code>date-fns-tz</code> como peer opcional y la de react-day-picker es experimental, pero en cualquiera de los dos casos el tipo del valor sigue siendo <code>Date</code>. Como un <code>Date</code> se interpreta en la zona horaria local del entorno de ejecución, en Seúl <code>new Date(2026, 3, 15)</code> pasado por <code>toISOString()</code> da <code>2026-04-14T15:00:00.000Z</code>. Eliges el 15 de abril y el servidor ve el 14. La issue <a href="https://github.com/Hacker0x01/react-datepicker/issues/1018" target="_blank" rel="noopener noreferrer">"Date Selected is One Day Off"</a> de react-datepicker se abrió en septiembre de 2017 y se cerró en diciembre de 2025.</p>
<p>En el otro extremo, Ark UI y React Aria usan objetos <code>@internationalized/date</code> como <code>CalendarDate</code> y <code>ZonedDateTime</code>. La semántica es precisa, pero en una app donde el estado de los formularios y las respuestas del servidor son todo cadenas, aparece código de conversión en cada frontera.</p>
<p>El soporte de zonas horarias también puede quedar atado a la biblioteca de fechas que elijas. En el código de los adaptadores de MUI X Date Pickers 9.13.0, los adaptadores de dayjs, Luxon y Moment tienen <code>isTimezoneCompatible = true</code>, y la familia date-fns tiene <code>false</code>. Una app que usa date-fns tiene que incorporar otra biblioteca de fechas para poder usar la prop <code>timezone</code>.</p>
<p>Los modos estaban repartidos en combinaciones de props, los valores en objetos <code>Date</code> cuya interpretación depende del entorno de ejecución y el soporte de zonas horarias en la elección de la biblioteca de fechas. <strong>Era difícil expresar la intención en una sola declaración.</strong></p>
<h3 id="aprender-construyendo"><a class="anchor" href="#aprender-construyendo">Aprender construyendo</a></h3>
<p>Un selector de fechas parece pequeño, pero contiene aritmética de calendario, locales, zonas horarias y horario de verano (DST), navegación por teclado y SSR. Por eso me puse como objetivo que, desde el lado de quien lo usa, se entendiera qué se está construyendo con solo mirar el JSX, y que, desde el lado de quien lo construye, el código fuera lo bastante acotado como para poder explicar dónde se convierten los valores.</p>
<p>Entonces, ¿de verdad no había ninguna biblioteca que ya resolviera estas necesidades?</p>
<h2 id="en-qué-se-diferencia-de-las-opciones-existentes"><a class="anchor" href="#en-qué-se-diferencia-de-las-opciones-existentes">En qué se diferencia de las opciones existentes</a></h2>
<p>La respuesta corta es que sí la había. Empecé convencido de que no existía ninguna biblioteca headless con varios pickers, pero al investigar de nuevo esa premisa resultó falsa.</p>
<h3 id="el-modelo-de-valor-que-eligió-cada-opción"><a class="anchor" href="#el-modelo-de-valor-que-eligió-cada-opción">El modelo de valor que eligió cada opción</a></h3>
<p>Lo siguiente se comprobó el 2026-09-16 instalando la última versión de npm y revisando las definiciones de tipos y la documentación oficial. La columna de tamaño se midió con el mismo método que explico más abajo.</p>
<table>
<thead>
<tr>
<th>Biblioteca</th>
<th>Headless</th>
<th>Tipo de valor</th>
<th>Entrada de hora</th>
<th>Selección solo de mes o año</th>
<th>gzip</th>
</tr>
</thead>
<tbody>
<tr>
<td>react-day-picker 10.0.1</td>
<td>No (incluye CSS)</td>
<td><code>Date</code></td>
<td>No</td>
<td>No</td>
<td>20.0KB</td>
</tr>
<tr>
<td>react-datepicker 9.1.0</td>
<td>No (import de CSS)</td>
<td><code>Date</code></td>
<td><code>showTimeSelect</code></td>
<td>Con props</td>
<td>45.4KB</td>
</tr>
<tr>
<td>MUI X 9.13.0</td>
<td>No (Material)</td>
<td>Objeto del adaptador</td>
<td>TimePicker</td>
<td><code>views</code></td>
<td>113.1KB</td>
</tr>
<tr>
<td>Ark UI 5.39.2</td>
<td>Sí</td>
<td><code>@internationalized/date</code></td>
<td>Segmentos con <code>DateInput</code> aparte (fuera del tamaño)</td>
<td><code>minView</code></td>
<td>42.7KB</td>
</tr>
<tr>
<td>React Aria Components 1.21.1</td>
<td>Sí</td>
<td><code>@internationalized/date</code></td>
<td>Segmentos de <code>TimeField</code></td>
<td>No</td>
<td>75.3KB (DatePicker), 78.8KB (con rango y hora)</td>
</tr>
<tr>
<td>Kalyx 1.4.7</td>
<td>Sí</td>
<td>Cadena ISO 8601 UTC</td>
<td>HourList y MinuteList en lista</td>
<td>MonthPicker, YearPicker</td>
<td>18.9KB (DatePicker), 25.6KB (todo)</td>
</tr>
</tbody>
</table>
<p>react-day-picker indica en su <a href="https://daypicker.dev/guides/timepicker" target="_blank" rel="noopener noreferrer">guía oficial</a> que "DayPicker does not include a built-in time picker". El tamaño de MUI incluye <code>@mui/material</code> y emotion, así que si tu app ya usa MUI, el aumento es mucho menor.</p>
<h3 id="ya-existen-opciones-headless-completas"><a class="anchor" href="#ya-existen-opciones-headless-completas">Ya existen opciones headless completas</a></h3>
<p>Las filas importantes de la tabla son Ark UI y React Aria. <a href="https://ark-ui.com/docs/components/date-picker" target="_blank" rel="noopener noreferrer">Ark UI</a> ofrece selección simple, múltiple y de rango, además de selección por mes y año, con una API de composición en dot notation como <code>DatePicker.Root</code>. <a href="https://react-aria.adobe.com/DatePicker" target="_blank" rel="noopener noreferrer">React Aria</a> es la implementación de Adobe centrada en la accesibilidad. Así que el hueco de Kalyx es más estrecho de lo que pensaba al principio.</p>
<blockquote>
<p>Ya existen opciones headless completas. Sin embargo, Ark UI y React Aria intercambian valores como objetos <code>@internationalized/date</code> y ofrecen la entrada de hora como campos segmentados. Kalyx fija los valores como cadenas de instante UTC que viajan tal cual en JSON, y reúne en la misma API de composición un TimePicker basado en listas y pickers de mes, año y semana.</p>
</blockquote>
<p>Aun así, "los valores son cadenas, y eso está bien" es un argumento débil. Un <code>CalendarDate</code> también se convierte en cadena con un solo <code>toString()</code>. El diferenciador no es el formato, sino <strong>el contrato de que esa cadena siempre es un momento real (un instante) y nunca se mezcla con una celda del calendario (una coordenada), y los tests que lo hacen cumplir</strong>.</p>
<h3 id="volví-a-medir-el-tamaño-del-bundle-con-un-mismo-método"><a class="anchor" href="#volví-a-medir-el-tamaño-del-bundle-con-un-mismo-método">Volví a medir el tamaño del bundle con un mismo método</a></h3>
<p>El tamaño del bundle del badge del README no era una cantidad comparable con la de otras bibliotecas. Los aproximadamente 19.5KB del badge corresponden a un único archivo de <code>@kalyx/react</code> en <code>dist</code>, y ese archivo deja <code>@kalyx/core</code>, <code>@kalyx/adapter-date-fns</code> y <code>@floating-ui/react</code> como imports externos. Había puesto esa cifra junto a las cifras de otras bibliotecas que incluían sus dependencias.</p>
<p>Así que lo volví a medir todo con esta pregunta: "¿cuánto crece el bundle cuando una app consumidora añade una línea de import?"</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>Medido el 2026-09-16 con esbuild 0.28.2; KB son bytes divididos entre 1024. En react-datepicker excluí el CSS. React Aria Components lo medí dos veces: con los 12 exports necesarios para montar un único DatePicker (<code>DatePicker</code>, <code>DateInput</code>, <code>Calendar</code>, <code>Popover</code>, <code>Dialog</code>, etc.) y con 14 exports que añaden rango y hora (incluidos <code>DateRangePicker</code>, <code>RangeCalendar</code> y <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="Con las mismas condiciones de esbuild, el tamaño crece en este orden: Kalyx DatePicker 18.9KB, react-day-picker 20.0KB, Kalyx completo 25.6KB, Ark UI 42.7KB, react-datepicker 45.4KB, React Aria Components 75.3KB (78.8KB con rango y hora), 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>En las mismas condiciones, hay una sola afirmación verdadera. Un DatePicker (18.9KB) tiene un tamaño parecido al de <code>DayPicker</code> (20.0KB), y los siete juntos (25.6KB) pesan más. (E incluso así, DayPicker es solo el calendario, mientras que Kalyx DatePicker incluye también el campo de entrada y el popover.) El tamaño es sensible a la combinación de imports y al nivel de gzip, así que conviene leerlo como orden de magnitud, y el tamaño no es la razón principal para elegir Kalyx.</p>
<h2 id="una-definición-técnica-de-kalyx"><a class="anchor" href="#una-definición-técnica-de-kalyx">Una definición técnica de Kalyx</a></h2>
<blockquote>
<p>Kalyx es un selector de fechas headless para React que fija todas las entradas y salidas como cadenas de instante UTC, confina la conversión entre coordenadas de calendario e instantes a dos funciones y comprueba ese viaje de ida y vuelta con tests de propiedades en todas las zonas horarias que conoce el runtime.</p>
</blockquote>
<p>Esta es la API del lado del consumidor. <code>value</code> y <code>onChange</code> usan <code>string | null</code>, y <code>displayTimezone</code> decide en el calendario de qué zona se muestra.</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="los-valores-son-instantes-y-las-celdas-del-calendario-son-coordenadas"><a class="anchor" href="#los-valores-son-instantes-y-las-celdas-del-calendario-son-coordenadas">Los valores son instantes y las celdas del calendario son coordenadas</a></h3>
<p>Por Kalyx circulan dos tipos de cadenas ISO con el mismo formato. Una <strong>coordenada</strong> es una celda de la cuadrícula del calendario; se escribe como <code>YYYY-MM-DDT00:00:00.000Z</code>, pero no tiene noción de zona horaria. El cálculo de la cuadrícula se hace solo en UTC, así que no depende del entorno de ejecución. Un <strong>instante</strong> es el valor que sale por <code>onChange</code>: el momento real que corresponde a la medianoche de ese día en <code>displayTimezone</code>. El mismo 15 de enero es <code>2026-01-15T05:00:00.000Z</code> en Nueva York y <code>2026-01-14T15:00:00.000Z</code> en Seúl.</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="La coordenada de calendario 2026-01-15T00:00:00.000Z se convierte en los instantes de Nueva York y Seúl mediante civilMidnightFromUtcDay, y vuelve a ser coordenada mediante calendarDayFromInstant." style="max-width:min(720px, 100%);height:auto;" width="1640" height="1040" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Solo dos funciones de <code>@kalyx/core</code> unen ambos mundos: <code>civilMidnightFromUtcDay</code>, que convierte una coordenada en instante, y su inversa, <code>calendarDayFromInstant</code>. Esta última lee "qué día es ese instante en esta zona" y lo vuelve a escribir como coordenada de medianoche UTC.</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>La regla es de dirección. Al elegir una celda y confirmar el valor, la coordenada pasa a instante; al decidir a partir del valor guardado qué mes mostrar y dónde poner el foco, el instante pasa a coordenada. El <code>selectDate</code> del Root de DatePicker convierte, valida las restricciones con el instante convertido y, si pasa, confirma.</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>No es el único punto de llamada. Si buscas en <code>packages/react/src</code>, las dos funciones aparecen repartidas por el Root y el Calendar de DatePicker, RangePicker y DateTimePicker, por Presets, por las utilidades de navegación con teclado y por seis hooks headless. Aun así, todo camino que confirma una celda de fecha o decide la vista pasa por una de las dos. La confirmación de una hora la hace <code>setTimeInTimezone</code>, que por dentro convierte con la misma función interna, <code>resolveCivilDateTime</code>.</p>
<p>Este contrato se protege con tests basados en propiedades (property-based tests). Para toda coordenada <code>c</code> y toda zona <code>z</code>, debe cumplirse <code>calendarDayFromInstant(civilMidnightFromUtcDay(c, z), z) === c</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/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> genera fechas aleatorias entre 2020 y 2045 y las combina con 14 zonas representativas, como Kathmandu (+5:45), Kiritimati (+14) y Niue (-11), durante 300 ejecuciones. El test inmediatamente siguiente comprueba el mismo viaje de ida y vuelta 12 veces por zona para todas las zonas que devuelve <code>Intl.supportedValuesOf('timeZone')</code>. En mi Node 24.16 local, esa lista tiene 418 zonas. La 1.4.8 añadió un test exhaustivo que compara, en cada cambio de horario, la conversión de las horas límite con una implementación de Temporal.</p>
<p>Entonces, ¿por qué no hacer que la biblioteca normalice por sí sola a instante un <code>"2026-01-15T00:00:00.000Z"</code> que pase el usuario? Ese camino está cerrado, porque con la cadena sola no se puede saber si es una coordenada o un instante. Si al valor de Seúl <code>2026-01-14T15:00:00.000Z</code>, que ya es un instante, se le vuelve a aplicar <code>civilMidnightFromUtcDay</code>, se obtiene <code>2026-01-13T15:00:00.000Z</code>; en zonas con offset positivo como Seúl, la fecha sigue retrocediendo un día en cada render. (El valor de Nueva York no cambia si se vuelve a aplicar.) Cambiarlo por <code>startOfDayInTimezone</code>, que da el mismo resultado por muchas veces que se aplique, elimina el desplazamiento, pero ya no cumple su función original de convertir coordenadas en instantes.</p>
<p>Así que renuncié a normalizar y fijé el contrato en la documentación. Quien usa el picker debe devolverle tal cual el valor que este emitió. Siendo sincero, es un coste que traslado a los consumidores, y como <code>ISODateString</code> es un alias de <code>string</code>, el compilador tampoco lo impide.</p>
<h3 id="resolver-el-dst-solo-con-intl"><a class="anchor" href="#resolver-el-dst-solo-con-intl">Resolver el DST solo con Intl</a></h3>
<p>Para convertir una coordenada en instante hay que saber cuándo es, en UTC, "las 00:00 de ese día en esa zona", pero el offset solo se puede obtener si ya conoces el instante. Además, en los días de cambio de horario la hora local puede no existir (spring forward) o existir dos veces (fall back).</p>
<p>Kalyx lo resuelve sin una biblioteca como <code>date-fns-tz</code>. Pregunta a <code>Intl.DateTimeFormat(...).formatToParts</code> "qué hora es este momento UTC en esa zona" para medir el offset, y lee ese offset en dos puntos: un día antes y un día después de la hora pedida.</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> es la hora local deseada leída como si fuera UTC. Los offsets están dentro de ±14 horas, así que un día antes y un día después dan el offset de cada lado de cualquier cambio cercano. Si ambos coinciden no hay cambio y basta con dos llamadas a <code>formatToParts</code>; como la cuadrícula la llama una vez por cada una de sus 42 celdas, esta vía rápida es la que determina el coste. (Supone como mucho un cambio en 48 horas, algo que se cumple en todas las zonas de 2020 a 2045.)</p>
<p>Si difieren, vuelve a leer ambos candidatos en esa zona y comprueba cuál coincide con la hora pedida (<code>timezone.ts:261-279</code>). En el solapamiento del fall back coinciden los dos, así que elige el más temprano, construido con el offset anterior al cambio; en el hueco del spring forward no coincide ninguno, así que elige ese mismo lado y avanza la hora la duración del hueco. Este es el resultado de ejecutarlo de verdad.</p>
<table>
<thead>
<tr>
<th>Petición</th>
<th>Situación</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>Hora inexistente</td>
<td><code>2026-03-08T07:30:00.000Z</code></td>
<td>Igual (03:30 EDT, hacia adelante)</td>
</tr>
<tr>
<td>New_York 2026-11-01 01:30</td>
<td>Hora que ocurre dos veces</td>
<td><code>2026-11-01T05:30:00.000Z</code></td>
<td>Igual (01:30 EDT, la más temprana)</td>
</tr>
<tr>
<td>London 2026-10-25 01:30</td>
<td>Hora que ocurre dos veces</td>
<td><code>2026-10-25T01:30:00.000Z</code> (la más tardía)</td>
<td><code>2026-10-25T00:30:00.000Z</code> (01:30 BST, la más temprana)</td>
</tr>
</tbody>
</table>
<p>"Los huecos avanzan y la ambigüedad toma la más temprana" coincide con el valor por defecto de <code>disambiguation</code>, <code>"compatible"</code>, que describe la <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/ZonedDateTime" target="_blank" rel="noopener noreferrer">documentación de Temporal.ZonedDateTime en MDN</a>. Por qué falla la fila de Londres en la 1.4.7 lo explico más adelante.</p>
<h3 id="la-frontera-del-adaptador-son-21-métodos-de-cadenas"><a class="anchor" href="#la-frontera-del-adaptador-son-21-métodos-de-cadenas">La frontera del adaptador son 21 métodos de cadenas</a></h3>
<p>Si core calcula las zonas horarias, ¿de qué se encargan date-fns o dayjs? De la aritmética de fechas y el parsing. Esa frontera, <code>DateAdapter</code>, tiene 21 métodos, y todos los argumentos de fecha y valores de retorno son cadenas ISO.</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>Los métodos que reciben zona horaria son cuatro, <code>format</code>, <code>isSameDay</code>, <code>startOfDay</code> y <code>today</code>, e incluso esos cuatro delegan el cálculo en core. El <code>format</code> del adaptador de date-fns, si recibe <code>timezone</code>, llama directamente a <code>formatInTimezone</code> de core (<code>packages/adapter-date-fns/src/index.ts:112-115</code>). Por eso, uses el adaptador que uses, las respuestas sobre zonas horarias salen del mismo código, y los tres adaptadores (date-fns, dayjs, luxon) toman de <code>@kalyx/core/test-helpers</code> la función <code>runAdapterConformanceTests</code> y la ejecutan cada uno para comprobar que dan las mismas respuestas.</p>
<p>Los dos entry points también se separan sobre esta frontera. El entry por defecto, <code>@kalyx/react</code>, llama a <code>setDefaultAdapter(DateFnsAdapter)</code> al cargarse el módulo, así que funciona nada más instalarlo (<code>packages/react/src/index.ts:9-11</code>). <code>@kalyx/react/headless</code> no hace esa llamada y se empaqueta por separado, así que no entra código de date-fns.</p>
<p>La forma de esta frontera tuvo un precio. En junio de 2026 abandoné un adaptador de Temporal. El valor de Temporal está en que <strong>los tipos llevan consigo el significado</strong>, como <code>PlainDate</code> y <code>ZonedDateTime</code>, pero si la frontera es una cadena, ese significado no puede atravesarla. Envolverlo solo lo aplanaría en una cadena, así que concluí que no aportaba ganancia de corrección. Visto ahora, el problema de la sección anterior, "coordenadas e instantes son el mismo <code>string</code>", es justo el que Temporal resuelve en el sistema de tipos con <code>PlainDate</code> e <code>Instant</code>. La frontera de cadenas facilitó cambiar de adaptador, pero a cambio cerró el camino de resolverlo con tipos.</p>
<h3 id="siete-pickers-que-combinan-tres-contextos"><a class="anchor" href="#siete-pickers-que-combinan-tres-contextos">Siete pickers que combinan tres contextos</a></h3>
<p>Kalyx tiene siete pickers: DatePicker, RangePicker, TimePicker, DateTimePicker, MonthPicker, YearPicker y WeekPicker. Lo importante no es el número, sino <strong>que los siete no son implementaciones independientes</strong>. Solo hay tres contextos: <code>DatePickerContext</code>, <code>RangePickerContext</code> y <code>TimePickerContext</code>.</p>
<table>
<thead>
<tr>
<th>Picker</th>
<th>Root que tiene el estado</th>
<th>Contexto</th>
<th>Dónde está la diferencia</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>La implementación del Root de DatePicker</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> tal cual</td>
<td>Range</td>
<td><code>selectionMode="week"</code> en Calendar</td>
</tr>
<tr>
<td>TimePicker</td>
<td><code>TimePickerRoot</code></td>
<td>Time</td>
<td></td>
</tr>
<tr>
<td>DateTimePicker</td>
<td>Root propio</td>
<td>Date y Time anidados</td>
<td>Superpone dos Providers</td>
</tr>
</tbody>
</table>
<p>El Root de MonthPicker se limita a cambiar el formato de visualización por defecto a <code>yyyy-MM</code> y a pasar <code>selectionGranularity="month"</code> a la implementación del Root de DatePicker (<code>MonthPicker/Root.tsx:11-20</code>). El <code>selectDate</code> de DatePicker, cuando la granularity es <code>month</code>, pliega la coordenada al día 1 de ese mes y después pasa por la misma conversión y validación de restricciones que vimos antes. DateTimePicker proporciona los dos contextos superpuestos.</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>Por eso <code>DateTimePicker.Calendar</code> es el mismo componente que <code>DatePicker.Calendar</code> y no sabe dentro de qué picker está. Una corrección en un sitio llega a todos los pickers que usan ese componente, y un defecto en un sitio también. Esta base compartida es además la razón de que un solo DatePicker supusiera el 74% del total en el gráfico anterior.</p>
<h2 id="lo-que-costó-mantener-el-contrato"><a class="anchor" href="#lo-que-costó-mantener-el-contrato">Lo que costó mantener el contrato</a></h2>
<p>Los tres meses desde la 1.0 los dediqué más a verificar este contrato que a nuevas funcionalidades, y aparecieron tres defectos que los tests basados en ejemplos habrían dejado pasar. El primero lo encontró un test de propiedades, el segundo una revisión cruzada del código y el tercero la verificación de este artículo.</p>
<h3 id="una-hora-del-1-de-octubre-en-sydney"><a class="anchor" href="#una-hora-del-1-de-octubre-en-sydney">Una hora del 1 de octubre en Sydney</a></h3>
<p>El primero fue que la propiedad "leer en esa zona el resultado de <code>startOfDayInTimezone</code> da 00:00:00" se rompió en Australia/Sydney. La implementación de entonces medía el offset una sola vez. Sydney pasa de +10 a +11 a las 02:00 del 1 de octubre de 2034, y las 00:00 de ese día todavía son +10, así que la respuesta correcta es <code>2034-09-30T14:00:00.000Z</code>. Pero "el punto en que las 00:00 del 1 de octubre se leen como UTC" cae después del cambio, así que devolvía +11, y el resultado eran las 23:00 del día anterior. Este contraejemplo sigue ahí como test de regresión (<code>timezone.property.test.ts:276</code>).</p>
<h3 id="un-test-que-solo-pasaba-en-seúl"><a class="anchor" href="#un-test-que-solo-pasaba-en-seúl">Un test que solo pasaba en Seúl</a></h3>
<p>El segundo salió de una revisión cruzada el 3 de agosto de 2026. El código que decide qué celda del calendario marcar como seleccionada convertía dos veces. La celda ya era una coordenada, pero convertía a fecha de esa zona tanto la celda como el valor guardado. Esto fue lo que produjo elegir "15 de enero".</p>
<table>
<thead>
<tr>
<th>Zona</th>
<th>Valor guardado</th>
<th>Celda marcada como seleccionada</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Asia/Seoul</code> (+9)</td>
<td><code>2026-01-14T15:00:00.000Z</code></td>
<td>Día 15 (correcto)</td>
</tr>
<tr>
<td><code>America/New_York</code> (-5)</td>
<td><code>2026-01-15T05:00:00.000Z</code></td>
<td>Día 16 (incorrecto)</td>
</tr>
</tbody>
</table>
<p>En las zonas con offset positivo los dos desplazamientos se compensaban y por casualidad acertaba, y los tests existentes solo cubrían Seúl. En la misma revisión apareció también una infracción en la dirección contraria. Al decidir qué mes mostrar, se aplicaba <code>startOfMonth</code> directamente a un instante, así que si en Seúl se pasaba el 1 de enero como valor, se abría el calendario de diciembre.</p>
<p>Los mecanismos son distintos, pero la regla incumplida es una sola: convertir una única vez por dirección, con la función que corresponde a esa dirección.</p>
<p>Este incidente amplió los tests de propiedades de ida y vuelta de core de "unas cuantas zonas representativas" a "todas las zonas que conoce el runtime". (Los tests de componentes del lado de React siguen usando zonas representativas como <code>America/New_York</code>.) <strong>Los defectos que solo aparecen donde cambia el signo no se atrapan con muestras.</strong></p>
<h3 id="la-0130-de-londres-que-se-resolvía-tarde"><a class="anchor" href="#la-0130-de-londres-que-se-resolvía-tarde">La 01:30 de Londres que se resolvía tarde</a></h3>
<p>El tercero surgió al extender a otras zonas la tabla de DST de este artículo. La 1.4.7 empezaba a medir el offset en el punto donde la hora pedida se lee como UTC, y en las zonas cuyo offset tras el cambio es 0 o más, ese punto ya está después del cambio, así que convergía en el offset tardío. Al contrastar con temporal-polyfill todos los cambios de 2020 a 2045 en 418 zonas, en 1908 de los 3395 cambios con solapamiento se elegía la hora más tardía, repartidos en 84 zonas. (Los 3394 cambios con hueco eran todos correctos, y Nueva York, con offset negativo, acertaba por casualidad.)</p>
<p>En <a href="https://github.com/jiji-hoon96/kalyx/pull/226" target="_blank" rel="noopener noreferrer">#226</a> lo cambié para leer un día antes y uno después, lo publiqué como <code>@kalyx/core</code> 1.4.8 y dejé la misma comparación como <code>timezone.dst-oracle.test.ts</code>. (temporal-polyfill solo se usa en los tests.) El comentario del código que decía erróneamente <code>'earlier'</code> ahora dice <code>'compatible'</code>. Otra vez, <strong>las muestras estaban concentradas en un solo signo.</strong></p>
<h3 id="los-3kb-invertidos-en-corrección"><a class="anchor" href="#los-3kb-invertidos-en-corrección">Los 3KB invertidos en corrección</a></h3>
<p>Estas correcciones costaron código. Kalyx fija en la CI un techo para el bundle del entry por defecto, y un PR que lo supera falla un check obligatorio. Ese techo empezó en 12KB y subía 1KB cada vez que entraba una funcionalidad, y en agosto de 2026, al rehacer por completo la corrección de zonas horarias y restricciones, lo subí de 17KB a 20KB de una vez.</p>
<p>El tamaño era un argumento de venta que aparecía en el badge del README. Un selector de fechas que se desplaza un día en zonas con offset negativo no sirve, sea pequeño o grande.</p>
<p><strong>Si tengo que elegir entre pequeño y correcto, elijo correcto.</strong> Ahora el techo va justo. Según el documento del mapa de bytes del bundle del repositorio con fecha 2026-09-11, <code>dist/index.cjs</code> medido con el gzip por defecto de Node ocupa 20 259 B frente a un techo de 20 480 B, con un margen de 221 B. (Es el tamaño del propio archivo con las dependencias como externas, así que es una cantidad distinta de la del gráfico anterior.) La próxima funcionalidad tendrá que recuperar bytes antes de poder entrar.</p>
<h2 id="una-conclusión-distinta-de-la-inicial"><a class="anchor" href="#una-conclusión-distinta-de-la-inicial">Una conclusión distinta de la inicial</a></h2>
<p>Lo que yo quería era usar bibliotecas de fechas complejas de forma declarativa. Al construirlo, descubrí que ya existían APIs de composición declarativas y opciones headless completas. La diferencia que quedaba estaba más adentro. <strong>Fijar el valor como un único instante, reducir la conversión entre coordenadas e instantes a dos funciones y proteger ese viaje de ida y vuelta con tests en todas las zonas horarias.</strong> El manejo del DST basado en Intl, la frontera de adaptador con cadenas y los siete pickers construidos con tres contextos son consecuencias de esa decisión.</p>
<p>Desde el objetivo de aprender, lo más importante que aprendí fue cómo afirmar que algo es "correcto". La comparación de bundles del artículo original medía cantidades distintas, y el test que pasaba en Seúl fallaba en Nueva York. Si no se anota también qué se midió y con qué muestra se comprobó, los números se convierten fácilmente en presunción.</p>
<p>Los límites también están claros. Soy el único maintainer, solo soporta React 19, y las descargas de npm de <code>@kalyx/react</code> entre el 5 y el 11 de septiembre de 2026 fueron 200, de las cuales 156 se concentraron en el día en que se publicó la 1.4.7. El problema de no poder separar coordenadas e instantes con tipos solo se contiene con documentación, y tampoco he decidido si garantizar como API pública los puntos de contacto para estilos, <code>classNames</code> y los atributos <code>data-*</code>. En accesibilidad tiene los roles <code>grid</code> en calendarios, <code>combobox</code> en campos de entrada y <code>listbox</code> en listas de horas, con navegación por flechas, Home/End y PageUp/PageDown, y la CI ejecuta las comprobaciones de <code>jest-axe</code> de 8 archivos de test, pero todavía no tengo base para decir que esté tan probada como React Aria.</p>
<p>Por eso, si tu app ya usa MUI, conviene mirar primero MUI X; si lo prioritario es la entrada por segmentos y una accesibilidad probada, React Aria; y si solo necesitas un calendario, react-day-picker. Si alguna vez has sufrido fechas que se desplazan un día en formularios donde los valores viajan como JSON, me alegraría que entonces le echaras un vistazo a Kalyx, y si conoces una solución mejor, te agradecería que me lo contaras en una 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>En el <a href="https://kalyx-docs-site.vercel.app/playground" target="_blank" rel="noopener noreferrer">Playground</a> del sitio de documentación puedes probar los siete pickers y cambiar tú mismo la configuración de locale y timezone.</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">Sitio de documentación oficial de Kalyx</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">Documentación oficial de Floating UI</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>bibliotecas</category>
            <category>React</category>
            <category>DatePicker</category>
            <category>código-abierto</category>
        </item>
        <item>
            <title><![CDATA[Cómo ahorrar tokens de IA]]></title>
            <link>https://hooninedev.com/es/260611</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260611</guid>
            <pubDate>Thu, 11 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de cómo ahorrar tokens de IA. Al principio me centraba más en los resultados y el proceso que en el rendimiento y el costo. Como los resultados generados por la IA tenía...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de cómo ahorrar tokens de IA.</p>
<p>Al principio me centraba más en los resultados y el proceso que en el rendimiento y el costo. Como los resultados generados por la IA tenían bastantes fallos, había que verificarlos; y como necesitábamos obtenerlos rápido, muchas personas, incluido yo, comprábamos más tokens o pasábamos a una suscripción superior cuando se agotaban. Yo también lo hice. (De hecho, durante los primeros meses ni siquiera presté atención a cuánto estaba gastando en tokens.)</p>
<p>Sin embargo, con el tiempo empecé a ser cada vez más consciente del uso de tokens. Para las personas, la cuota mensual pesaba; para las empresas, crecían las dudas sobre los costos laborales y operativos. En <a href="/260524" target="_blank" rel="noopener noreferrer">MCP y function calling</a> expliqué el protocolo que conecta a los agentes con sistemas externos; en <a href="/260526" target="_blank" rel="noopener noreferrer">Cuatro niveles de inteligencia de código</a>, las herramientas de búsqueda de código y GitHub Trending; y en <a href="/260529" target="_blank" rel="noopener noreferrer">Archivos de contexto</a>, los archivos de reglas que leen los agentes. Así, mis artículos hasta ahora se han centrado más en qué herramientas existen y cómo aprovecharlas bien. Sigo pensando que esos temas importan, pero con el tiempo el costo terminará siendo la pregunta que más interese.</p>
<p>En otro artículo, <a href="/260610" target="_blank" rel="noopener noreferrer">Cómo funcionan los tokens</a>, expliqué qué son exactamente, cómo los crea BPE y qué ocurre dentro de un transformador cuando prompt caching reduce el precio unitario. Sobre esa base, aquí veremos primero cómo se factura el costo y dónde nacen las ineficiencias, después ordenaremos patrones de ahorro comprobados. Los patrones principales van acompañados del uso de tokens que medí yo mismo ejecutando la misma tarea con distintas estrategias.</p>
<h2 id="de-dónde-surge-el-costo-de-los-tokens"><a class="anchor" href="#de-dónde-surge-el-costo-de-los-tokens">¿De dónde surge el costo de los tokens?</a></h2>
<p>Veamos de forma breve y directa cómo se generan los costos de tokens y cómo los proveedores los facturan.</p>
<p>Todo el texto que enviamos —prompt del sistema, definiciones de herramientas, historial de conversación y mensajes del usuario— se convierte en tokens de entrada, mientras que la respuesta del modelo se convierte en tokens de salida. Para el modelo, cada llamada es una entrada nueva que nunca ha visto. Da igual si la conversación fue ayer o si la llamada anterior ocurrió hace un minuto: en la nueva llamada se vuelve a enviar todo el contenido como entrada. (Este hecho tan simple es la clave del costo: el modelo no tiene memoria y nosotros se lo contamos todo otra vez.)</p>
<p>Aquí aparece otra variable. Volver a transmitir desde cero la parte estática de una llamada repetida es caro, por lo que los principales proveedores de LLM introdujeron prompt caching. La entrada estática se guarda una vez en caché y, en llamadas posteriores, los tokens leídos desde allí se cobran mucho más baratos. (En Cómo funcionan los tokens expliqué el mecanismo interno: cómo BPE crea tokens y cómo la caché reutiliza la KV cache del transformador para reducir el precio. Aquí nos centraremos en cómo ese mecanismo se traduce en costo.)</p>
<h3 id="tokens-de-entrada-tokens-de-salida-y-tokens-de-caché"><a class="anchor" href="#tokens-de-entrada-tokens-de-salida-y-tokens-de-caché">Tokens de entrada, tokens de salida y tokens de caché</a></h3>
<p>Veamos cuatro campos del objeto <code>usage</code> que devuelve el SDK de Anthropic.</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="El objeto usage de una respuesta del SDK de Anthropic, con los campos input_tokens, cache_creation_input_tokens, cache_read_input_tokens y output_tokens" width="916" height="638" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<ul>
<li><code>input_tokens</code> la parte de la entrada enviada que excluye las lecturas de caché</li>
<li><code>output_tokens</code> la respuesta generada por el modelo</li>
<li><code>cache_creation_input_tokens</code> los tokens guardados por primera vez en caché en esta llamada</li>
<li><code>cache_read_input_tokens</code> los tokens releídos desde una caché existente</li>
</ul>
<p>A cada campo se le aplica una tarifa distinta. Los tokens de salida son los más caros y los leídos desde caché, los más baratos. Según la documentación oficial de Anthropic, una lectura de caché cuesta 0,1 veces la tarifa base de entrada, exactamente un 10 %. La escritura cuesta 1,25 veces la tarifa base con un TTL (Time To Live, periodo de validez de la caché) de cinco minutos y 2 veces con un TTL de una hora. Es decir, se paga un poco más en la primera llamada y se ahorra un 90 % a partir de la segunda.</p>
<p>La caché también tiene varias restricciones poco conocidas. El mínimo de tokens almacenables varía según el modelo e incluso según la versión de una misma familia. De acuerdo con Anthropic, Sonnet 4.6 y Opus 4.8 exigen 1.024 tokens, Opus 4.7 exige 2.048, y Haiku 4.5 junto con los antiguos Opus 4.5/4.6 exigen 4.096. Un prompt más corto no se almacena aunque se añada <code>cache_control</code>, sin mostrar ningún aviso. Una solicitud admite como máximo cuatro puntos de corte <code>cache_control</code>, y la caché se lee jerárquicamente en el orden <code>tools</code> → <code>system</code> → <code>messages</code>. Por eso, cambiar una sola definición de herramienta al principio invalida toda la caché posterior.</p>
<p><img src="/content/260611/2.webp" alt="Prompt caching exige una coincidencia exacta desde el inicio; cambiar el prefijo invalida todo lo almacenado después" width="1680" height="840" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>El descuento del 90 % nace de reutilizar entre llamadas la KV cache del transformador, mecanismo que expliqué en detalle en Cómo funcionan los tokens. Desde la perspectiva del costo, basta recordar algo: solo hay acierto de caché cuando el prefijo coincide exactamente. Por eso hay que poner <strong>el contenido estático al principio y el contenido dinámico que cambia en cada llamada al final</strong>. Incluso un solo carácter de una marca temporal al inicio del prompt invalida toda la caché que viene después.</p>
<p>Surge entonces una duda natural: «Pero la entrada cambia en cada turno de una conversación, ¿no debería romperse la caché casi siempre?». La respuesta corta es no. La entrada conversacional no se reescribe como un bloque nuevo en cada turno; sigue una <strong>estructura append que conserva lo acumulado al principio y solo agrega la nueva intervención al final</strong>. Una vez fijados el prompt del sistema, las herramientas y las preguntas y respuestas anteriores, permanecen intactos; solo se añade la pregunta más reciente. Si comparamos desde el inicio la entrada actual con la anterior, la primera diferencia siempre está en esa nueva pregunta final. La entrada completa cambia, pero casi todo el prefijo sigue igual y la caché sobrevive. (Solo se rompe en cada turno cuando un diseño defectuoso inserta valores dinámicos al principio. Herramientas como Claude Code y opencode mantienen fijo el inicio y anexan al final, por lo que el usuario no debe configurar nada. Los errores ortográficos tampoco afectan por la misma razón: aparecen al final, fuera del prefijo ya almacenado.)</p>
<h3 id="comparación-de-proveedores-de-ia"><a class="anchor" href="#comparación-de-proveedores-de-ia">Comparación de proveedores de IA</a></h3>
<p>Al reunir las tarifas oficiales de Anthropic, OpenAI y Google vigentes en junio de 2026, el patrón se ve de inmediato. (Precios en USD por millón de tokens, con los modelos más habituales en flujos de programación.)</p>
<table>
<thead>
<tr>
<th>Proveedor</th>
<th>Modelo</th>
<th>entrada</th>
<th>entrada en caché</th>
<th>salida</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>En los tres proveedores, los tokens de salida cuestan entre cinco y seis veces más que los de entrada. Por tanto, una respuesta más larga eleva rápidamente el costo aunque diga lo mismo. Dentro de una misma gama, la diferencia de precio entre modelos también llega a ser de tres a seis veces. La aritmética señala tres principios centrales: <strong>pedir respuestas más breves, usar un modelo más barato si produce la misma respuesta y almacenar en caché la entrada estática</strong>. (Otro cálculo resulta revelador. Si reutilizamos 100 veces un contexto estático de 10.000 tokens, con la tarifa base de GPT-5.5 cuesta $5 y leído desde caché cuesta $0.50. La caché ahorra $4.5 en esas 100 reutilizaciones y, como la primera escritura casi no tiene recargo, empieza a compensar desde la segunda llamada.)</p>
<h3 id="diferencias-entre-los-mecanismos-de-caché"><a class="anchor" href="#diferencias-entre-los-mecanismos-de-caché">Diferencias entre los mecanismos de caché</a></h3>
<p>Aunque los descuentos sean similares, el diseño interno cambia según el proveedor. Entender esas diferencias facilita decidir una política.</p>
<table>
<thead>
<tr>
<th>Elemento</th>
<th>Anthropic</th>
<th>OpenAI</th>
<th>Google Gemini</th>
</tr>
</thead>
<tbody>
<tr>
<td>Activación</td>
<td>Puntos de corte <code>cache_control</code> explícitos</td>
<td>Automática (sin cambios de código)</td>
<td>Caché automática (implicit) y explícita (explicit)</td>
</tr>
<tr>
<td>Tamaño mínimo</td>
<td>1.024–4.096 tokens (según modelo)</td>
<td>1.024+ (incrementos de 128)</td>
<td>2.048–4.096 (según modelo)</td>
</tr>
<tr>
<td>Costo de escritura</td>
<td>1.25x (5 min) / 2x (1h) de la tarifa de entrada</td>
<td>Gratis</td>
<td>Gratis (con tarifa de almacenamiento por hora)</td>
</tr>
<tr>
<td>Costo de lectura</td>
<td>0.1x de la tarifa de entrada</td>
<td>Cerca de 0.1x</td>
<td>0.1x de la tarifa de entrada</td>
</tr>
<tr>
<td>TTL</td>
<td><a href="https://github.com/anthropics/claude-code/issues/46829" target="_blank" rel="noopener noreferrer">5 minutos o 1 hora (a elección del usuario)</a></td>
<td>5–10 minutos de inactividad por defecto, hasta 1 hora (24 horas al ampliar)</td>
<td>Definido por el usuario (almacenamiento por hora)</td>
</tr>
<tr>
<td>Costo adicional</td>
<td>Ninguno</td>
<td>Ninguno</td>
<td>Almacenamiento: Flash $1/M-hora, Pro $4.50/M-hora</td>
</tr>
</tbody>
</table>
<p>La filosofía de cada proveedor queda clara. <strong>Anthropic</strong> pide marcar explícitamente qué se almacena, cobra una pequeña prima en la primera llamada (1,25 veces) y después ofrece un gran descuento. Al poder controlar la posición del prefijo, la tasa de lecturas resulta predecible. <strong>OpenAI</strong> automatiza todo: desde 1.024 tokens la entrada se almacena sin costo adicional, pero el usuario tiene menos control. <strong>Google</strong> ofrece ambos sistemas y cobra almacenamiento separado cuando el usuario gestiona la caché de forma explícita. La caché implícita conviene para reutilizaciones breves y frecuentes; la explícita con almacenamiento, para contextos grandes que deben conservarse una hora o más.</p>
<h3 id="definiciones-de-herramientas-y-tokenizadores"><a class="anchor" href="#definiciones-de-herramientas-y-tokenizadores">Definiciones de herramientas y tokenizadores</a></h3>
<p>Hay otras dos variables con un impacto sorprendentemente grande.</p>
<p>La primera son las definiciones de herramientas. Cuando hay varios servidores MCP conectados, cada llamada incluye todos los nombres y esquemas de parámetros —más adelante mediremos cuánto infla esto el costo—. Incluso con las mismas herramientas, cambiar solo el modelo altera el gasto. La documentación de Anthropic muestra que la longitud del prompt de sistema para herramientas varía: con <code>tool_choice: auto</code>, Sonnet 4.6 y Haiku 4.5 usan unos 497 tokens, Opus 4.7 usa 675 y Opus 4.8 baja a 290. Antes del model routing conviene comprobar cuánto expande el modelo actual las definiciones, porque la diferencia puede ser considerable.</p>
<p>La segunda es la eficiencia del tokenizador, que divide el texto humano en unidades procesables por el modelo. Como vimos en Cómo funcionan los tokens, un mismo texto puede producir cantidades distintas según el tokenizador. o200k_base de OpenAI usa muchos menos tokens que cl100k_base con textos no ingleses, y Anthropic indica que, tras adoptar un nuevo tokenizador desde Opus 4.7, el mismo texto puede facturarse con hasta un 35 % más de tokens que en modelos anteriores. Elegir solo por tarifa puede hacer que el costo reaparezca por ineficiencia del tokenizador. Una comparación honesta multiplica precio unitario por cantidad esperada de tokens.</p>
<p>La diferencia destaca especialmente en coreano. Al tokenizar las mismas frases con dos tokenizadores de OpenAI, las oraciones técnicas en inglés dieron la misma cantidad, pero cl100k_base utilizó entre un 31 y un 43 % más tokens para el coreano que o200k_base. Un párrafo coreano de 167 caracteres llegó a convertirse en 169 tokens con el sistema antiguo: <strong>más tokens que caracteres</strong>. En promedio, cada carácter coreano consumió más de un 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="Comparación de tokens en coreano e inglés por tokenizador: cl100k frente a o200k" style="max-width:min(720px, 100%);height:auto;" width="1680" height="697" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>El tokenizador antiguo fragmenta el coreano de forma agresiva a nivel de bytes, mientras que el nuevo agrupa expresiones frecuentes como «개발» o «입니다» en un único token. Así, aun con la misma tarifa, una carga de trabajo centrada en coreano puede costar más de 1,5 veces solo por la eficiencia del tokenizador.</p>
<p>Llegados aquí surge otra pregunta: «¿Dónde estamos generando exactamente toda esta ineficiencia?».</p>
<h2 id="patrones-habituales-que-desperdician-tokens"><a class="anchor" href="#patrones-habituales-que-desperdician-tokens">Patrones habituales que desperdician tokens</a></h2>
<p>El desperdicio es sorprendentemente común incluso cuando creemos usar bien la IA. Los patrones que he observado en mi trabajo y entre otros desarrolladores se agrupan así.</p>
<h3 id="demasiados-servidores-mcp-y-definiciones-de-herramientas"><a class="anchor" href="#demasiados-servidores-mcp-y-definiciones-de-herramientas">Demasiados servidores MCP y definiciones de herramientas</a></h3>
<p>Ya lo mencioné, pero merece repetirse. Una vez conectados servidores MCP como Linear, GitHub, Notion, Figma, Slack o Sentry, rara vez se eliminan. Los esquemas sin usar inflan la entrada en cada llamada. Para resolverlo, Claude Code activa por defecto MCP Tool Search: al iniciar la sesión solo carga los nombres y descripciones de servidores, y el esquema completo aparece cuando el modelo llama realmente a la herramienta.</p>
<p>Medí el tamaño de la diferencia. Con 27 herramientas MCP de una sesión de Claude Code —10 de Serena, ocho de cuatro integraciones OAuth de claude.ai, dos de Figma y siete de agentmemory— envié <strong>el mismo mensaje de usuario con dos configuraciones</strong>. Una no tenía MCP; la otra tenía las 27 conectadas pero no disponibles para el modelo. En ambos casos hubo cero llamadas a herramientas.</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="La misma pregunta lanzada sin servidores MCP y con 27 conectados: los tokens de entrada pasan de 41 a 10.335 y el coste por llamada sube unas 11,7 veces" width="1680" height="638" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Con la misma pregunta, el mismo modelo y una respuesta equivalente, la entrada pasó de <strong>41 → 10,335 (+10,294)</strong> tokens en Opus 4.7. El costo por llamada subió de <strong>$0.0048 → $0.0563, unas 12 veces</strong>, y el aumento de 250 veces en la entrada añadió <strong>+783ms</strong> de latencia por la carga de prefill. Más llamativo que el importe fue que era <strong>un costo pagado en cada llamada aunque el usuario no usara ninguna herramienta MCP en ese turno</strong>. Ese es el costo que evita Tool Search. (Desde esta medición, depuro continuamente los servidores MCP que no uso.)</p>
<h3 id="acumulación-de-contexto-y-lost-in-the-middle"><a class="anchor" href="#acumulación-de-contexto-y-lost-in-the-middle">Acumulación de contexto y 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="Primera página del artículo &#x22;Lost in the Middle&#x22;, con la curva en U donde la precisión cae al mínimo cuando la respuesta está en medio del contexto" width="840" height="727" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Arrastrar una conversación larga no solo aumenta los tokens de entrada por llamada: también reduce la precisión. El artículo «Lost in the Middle» del equipo de Liu en Stanford cuantificó una curva en U: los modelos recuperan mejor la información clave al principio o al final y empeoran cuando queda enterrada en medio. Es la peor combinación: gastar más tokens para obtener una respuesta peor. Como el cálculo de self-attention crece con el cuadrado de los tokens, la atención disponible para cada uno se diluye al alargarse el contexto. El centro se debilita primero porque los datos de entrenamiento suelen concentrar la información importante en los extremos.</p>
<p>Si profundizamos, la forma en que el modelo trata la «posición» contiene dos tendencias que entierran el centro. Suenan técnicas, pero la intuición es sencilla.</p>
<p>Primero, el modelo <strong>escucha más a los tokens cercanos</strong>. RoPE (Rotary Position Embedding), la representación posicional usada por la mayoría de modelos abiertos actuales, conecta más débilmente dos tokens cuanto más separados están: un decay effect en el que la señal se desvanece con la distancia. Por eso los tokens lejanos reciben menos atención.</p>
<p>Segundo, el modelo <strong>envía demasiada atención al primer token</strong>. Debe repartir siempre el 100 % de su atención —softmax obliga a que los pesos sumen 1— y, cuando no hay nada especial que atender, tiene que desechar el sobrante en algún lugar, normalmente el primer token. Este fenómeno se llama attention sink. (El equipo de Xiao en el MIT lo cuantificó por primera vez en StreamingLLM. No ocurre porque el primer token sea importante, sino porque funciona como desagüe de atención sobrante.)</p>
<p>Juntas, ambas tendencias concentran la atención en los dos extremos —los tokens recientes cercanos y el primero—, mientras que la información del centro recibe el trato más débil. No es un fallo de un modelo concreto. LLaMA, Mistral y Qwen usan mecanismos de la familia RoPE, y se cree que Claude y GPT emplean enfoques similares. Lost in the middle es, por tanto, un sesgo común de la arquitectura moderna de transformadores.</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="Gráfico de context rot: a medida que crece la longitud de entrada, la precisión cae en Claude, Qwen, OpenAI y Gemini por igual" width="1200" height="622" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Más recientemente, el fenómeno se denomina <strong>context rot</strong>. Un <a href="https://research.trychroma.com/context-rot" target="_blank" rel="noopener noreferrer">análisis del equipo de Chroma</a> sometió a 18 modelos de frontera —GPT-4.1, Claude 4, Gemini 2.5 y Qwen3, entre otros— a la misma tarea NIAH (needle in a haystack). Cuando la entrada creció de 10k a más de 100k tokens, la precisión cayó entre un 20 y un 50 % según el modelo. Los 18 empeoraron y la familia Claude lo hizo más lentamente. Anthropic también lo explica como un problema de «presupuesto de atención» derivado de la atención n², que se consume entre los tokens. Mantener ligero el contexto reduce costos y protege la precisión.</p>
<h3 id="llamar-subagent-sin-criterio"><a class="anchor" href="#llamar-subagent-sin-criterio">Llamar subagent sin criterio</a></h3>
<p>Delegar todo porque los subagent son útiles es otra trampa. Un subagent comienza en un contexto separado, así que paga desde cero el costo fijo del prompt de sistema y las herramientas. Si se delega un comando breve de shell o una consulta simple de git, ese arranque puede superar cualquier ahorro en el contexto principal. Según el informe de Anthropic sobre su sistema multiagente, un agente usa unas cuatro veces más tokens que un chat normal y un sistema multiagente unas quince veces más. Delegar solo tiene sentido si la mejora de precisión justifica ese costo.</p>
<p>La misma trampa aparece al conectar herramientas MCP. Cada herramienta activa añade su esquema al prompt del sistema en cada turno y se factura aunque no se use. «Activarlo todo por si acaso» puede parecer más seguro, pero las mediciones dicen otra cosa.</p>
<p>Probé precisión y costo con el mismo lote de preguntas sobre el código del reconciler de facebook/react v19, dividido en tres configuraciones:</p>
<ul>
<li>Sin herramientas</li>
<li>Una herramienta (CodeGraph, Serena, ripgrep o grep básico)</li>
<li>Las cuatro herramientas conectadas a la vez</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 y uso de tokens por escenario de herramientas en el benchmark de codesearch" style="max-width:min(500px, 100%);height:auto;" width="1680" height="1927" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Destacaron tres resultados. (Aquí recall indica cuán completas fueron las respuestas correctas.)</p>
<ul>
<li><strong>Sin herramientas</strong> obtuvo un recall medio de solo 0,31, lo que confirma que las herramientas aportan valor.</li>
<li><strong>Solo Serena (LSP)</strong> logró 1,00 de recall por $0.38, la estrategia individual más eficiente.</li>
<li><strong>Las cuatro herramientas conectadas</strong> bajaron el recall a 0,89 y elevaron el costo a $0.47. En preguntas multi-hop, su puntuación coincidió con CodeGraph solo (0.78 / 0.88) hasta el segundo decimal: el modelo tendió a una herramienta y heredó sus debilidades.</li>
</ul>
<p>Un principio sirve tanto para subagent como para las herramientas: el costo extra solo compensa cuando mejora la precisión. <strong>Elegir la herramienta adecuada para el dominio es más barato y preciso que activarlas todas por si acaso.</strong></p>
<p>Entonces, ¿qué métodos comprobados permiten ahorrar tokens? ¿Existen estrategias que funcionen de verdad?</p>
<h2 id="formas-comprobadas-de-ahorrar-tokens"><a class="anchor" href="#formas-comprobadas-de-ahorrar-tokens">Formas comprobadas de ahorrar tokens</a></h2>
<p>Cada patrón ataca un eje distinto: algunos reducen la entrada, otros abaratan la misma entrada y otros asignan el mismo trabajo a un modelo más económico. Veámoslos uno a uno.</p>
<h3 id="prompt-caching"><a class="anchor" href="#prompt-caching">Prompt caching</a></h3>
<p>Es el efecto más inmediato. Como vimos, las lecturas de caché cuestan 0,1 veces la tarifa base de entrada. Tras pagar una pequeña prima de escritura de 1,25 veces en la primera llamada, la misma sección estática puede reutilizarse desde la segunda a una décima parte del precio.</p>
<p>Desde la perspectiva del ahorro, hay un punto clave: en cualquier sistema de caché, <strong>las partes casi idénticas entre llamadas</strong> —herramientas, fragmentos de código y contexto RAG— son precisamente las almacenables. El beneficio se mantiene si esos bloques no se mezclan con contenido dinámico, como la pregunta actual o el resultado recién llegado de una herramienta. Al llamar directamente a la API, agrupar lo estático al principio y lo dinámico después logra casi todo el ahorro. En Claude Code u opencode, la propia herramienta ordena esos bloques.</p>
<h3 id="agrupar-trabajo-asíncrono-con-batch-api"><a class="anchor" href="#agrupar-trabajo-asíncrono-con-batch-api">Agrupar trabajo asíncrono con Batch API</a></h3>
<p>Cuando una llamada no necesita terminar de inmediato, Batch API es la forma más sencilla de reducir la tarifa. Anthropic, OpenAI y Google descuentan un 50 % tanto en entrada como en salida a cambio de entregar el resultado en un máximo de 24 horas. En Anthropic, la mayoría de los lotes termina en menos de una hora, pero el SLA es de 24 horas. La llamada no cambia y el precio se reduce a la mitad, por lo que el costo de adopción es mínimo.</p>
<p>Ese 50 % resulta especialmente interesante porque se acumula multiplicativamente con otros ahorros. Anthropic dice de forma explícita que los descuentos de caché y lote se combinan. Para entrada estática: tarifa estándar × 0.5 (lote) × 0.1 (lectura de caché) = 0.05 veces, es decir, un 5 % del precio estándar. La clave es que se multiplican, no se suman.</p>
<p>Calculé la diferencia. Supongamos 100 trabajos con 10.000 tokens de contexto estático, 500 de entrada dinámica y 1.000 de salida. Con las tarifas de Opus 4.8, comparé cuatro estrategias.</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="Ahorro de tokens con Batch API y Prompt caching: los descuentos se acumulan de forma multiplicativa" style="max-width:min(720px, 100%);height:auto;" width="1680" height="670" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Solo la caché reduce el costo un 57 %, solo el lote un 50 %, y ambos juntos hasta un 79 %. Se multiplican en la parte estática en lugar de sumarse. (La salida no se almacena y solo recibe el 50 % del lote; cuanto mayor sea su proporción, menor será el ahorro total respecto al 79 %. Cuanto más pese la entrada estática, mayor será el efecto.) Hay muchos trabajos donde nadie necesita esperar ante la pantalla: indexación nocturna, evaluación de artículos antes de publicar, extracción de datos e informes periódicos.</p>
<p>No todo puede aplazarse. Batch no encaja cuando el tiempo de respuesta aporta valor, como en la sesión principal de un agente de programación o un chat interactivo. Separar el trabajo en «resultado inmediato» y «resultado antes del siguiente día laboral» puede reducir la factura a la mitad.</p>
<h3 id="aislar-trabajo-verbose-con-un-subagent"><a class="anchor" href="#aislar-trabajo-verbose-con-un-subagent">Aislar trabajo verbose con un subagent</a></h3>
<p><img src="/content/260611/7.webp" alt="Estructura de aislamiento de contexto mediante subagent" 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">En palabras de la documentación oficial de Claude Code,</a> un subagent «opera en su propia ventana de contexto aislada; las llamadas y resultados intermedios permanecen dentro del subagent y solo el mensaje final vuelve al padre». Delegar una tarea verbose deja así solo un resumen limpio en el contexto principal. Según el artículo de Anthropic sobre <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">context engineering</a>, aunque un subagent use decenas de miles de tokens explorando, suele devolver al padre un resumen comprimido de 1.000 a 2.000. El beneficio para el contexto principal es claro.</p>
<blockquote>
<p>Una tarea verbose produce una enorme cantidad de tokens para encontrar una respuesta de una línea. Ejecutar pruebas, buscar documentación y analizar registros son ejemplos típicos.</p>
</blockquote>
<p>Hay que aclarar un malentendido: un subagent no reduce automáticamente el costo total. El informe de Anthropic dice que los agentes usan unas cuatro veces más tokens que un chat y los sistemas multiagente unas quince veces más. Un subagent protege la precisión y el costo de un contexto principal largo al extraer de él la salida verbose; no es magia que siempre reduzca el gasto. Por eso solo conviene delegar si el ahorro de limpieza del contexto supera el costo de arranque. Para tareas cortas, es más barato que el padre actúe directamente.</p>
<p>También importa el dominio. El informe de Anthropic afirma que, en su evaluación interna, un líder Opus 4 con subagent Sonnet 4 mejoró un 90,2 % frente a un único Opus 4. Pero la mejora no es uniforme. El mismo informe advierte que los dominios donde los agentes deben compartir contexto o tienen muchas dependencias no encajan bien, y señala la programación como caso exacto. La investigación permite explorar direcciones independientes en paralelo; el código vive en un grafo de dependencias. (Si usa una configuración multiagente para programar, conviene comprobar si el flujo realmente es exploración paralela. Un agente único con un subagent de exploración aislado puede ser una base más segura.)</p>
<h3 id="compact-y-progressive-disclosure"><a class="anchor" href="#compact-y-progressive-disclosure">compact y progressive disclosure</a></h3>
<p>El comando <code>/compact</code> de Claude Code comprime toda la conversación en un resumen y reinicia con un contexto nuevo. Según Anthropic, no es un simple recorte sino un resumen semántico: conserva el trabajo en curso y los cambios recientes, y elimina salidas repetitivas que probablemente no vuelvan a consultarse. A diferencia de <code>/clear</code>, que borra todo, <code>/compact</code> deja un resumen. Cuando la ventana llega cerca del 95 %, auto-compact hace lo mismo automáticamente. Ordenar una sesión larga corta la acumulación de entrada.</p>
<p>Mirando más adentro, <code>/compact</code> es solo la última etapa que el usuario puede activar; antes funciona una tubería automática de cuatro etapas. Según <a href="https://github.com/VILA-Lab/Dive-into-Claude-Code" target="_blank" rel="noopener noreferrer">el estudio externo «Dive into Claude Code»,</a> <code>query.ts</code> revisa estas cinco etapas antes de cada llamada.</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="Diagrama del bucle del agente: arranca en el prompt del usuario, repite peticiones de herramienta y comprobaciones de permiso, y pasa por la compactación cuando aprieta el contexto" width="1680" height="1591" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<ul>
<li><strong>Budget Reduction</strong> recorta las partes de cada salida de herramienta que superan el límite.</li>
<li><strong>Snip</strong> elimina el historial más antiguo en el eje temporal.</li>
<li><strong>Microcompact</strong> realiza una compresión fina sin perder conciencia de caché.</li>
<li><strong>Context Collapse</strong> reproyecta el historial muy largo en read-time para reducir su dimensión.</li>
<li><strong>Auto-Compact</strong> activa la compresión semántica al 95 % como último recurso.</li>
</ul>
<p>Las etapas superiores son más ligeras y baratas; las inferiores, más pesadas pero eficaces. El diseño reconoce que una sola estrategia no resuelve toda presión de contexto. Invocar <code>/compact</code> equivale aproximadamente a adelantar la última etapa automática.</p>
<p>La misma idea aparece en la arquitectura Skills de Claude Code. Si <code>/compact</code> reduce el contexto ya acumulado, la carga en tres fases de <code>/skills</code> evita que se acumule desde el principio.</p>
<p>Según Anthropic, una skill se carga en tres fases. Al inicio solo entran su nombre y descripción breve —unos 100 tokens—. El cuerpo (<code>SKILL.md</code>, menos de 5.000 tokens) se carga cuando se activa. Si se ejecutan scripts o recursos mediante bash, solo vuelve la salida; el código no entra al contexto. Por eso instalar decenas de skills apenas aumenta el contexto inicial.</p>
<h3 id="model-routing-un-modelo-más-barato-para-la-misma-respuesta"><a class="anchor" href="#model-routing-un-modelo-más-barato-para-la-misma-respuesta">Model routing: un modelo más barato para la misma respuesta</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="Rendimiento del modelo frente al coste en escala logarítmica, con el enrutador ideal en la esquina de bajo coste y alto rendimiento" width="1172" height="1039" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>La entrada de Opus 4.8 cuesta cinco veces la de Haiku 4.5. Usar el modelo mayor para toda búsqueda simple, exploración o resumen breve desperdicia dinero. Encaminar según dificultad —Haiku → Sonnet → Opus— y reservar Opus para razonamiento pesado se está volviendo estándar. <a href="https://lmsys.org/blog/2024-07-01-routellm/" target="_blank" rel="noopener noreferrer">La investigación RouteLLM de LMSYS</a> mostró un router que conservó el 95 % de la calidad de GPT-4 reduciendo al 14 % las llamadas al modelo fuerte, aunque el benchmark era de razonamiento general y no de programación.</p>
<p>El ecosistema también se ha asentado. Entre las pasarelas comerciales están OpenRouter, Martian y NotDiamond; en código abierto, RouteLLM de LMSYS y capas de observabilidad de costos como LiteLLM y Bifrost. En Anthropic, el argumento de selección de modelo del Agent SDK, el campo <code>model</code> del subagent de Claude Code —Explore usa Haiku por defecto— y el comando <code>/model</code> sirven para este routing. Hay una salvedad: al llamar directamente a una API, esta no evalúa la dificultad para elegir modelo. Las API de Anthropic, OpenAI y Google ejecutan exactamente el nombre indicado. El routing automático es una opción de producto, como Auto de Cursor, el modo auto de ChatGPT u <code>openrouter/auto</code>. Para ahorrar así hay que construirlo con una pasarela o clasificador.</p>
<p>Eso no significa que convenga añadir cualquier router automático. Auto Router de OpenRouter, que cambia el modelo en cada llamada, choca con prompt caching. La caché efímera de Anthropic solo acierta con el mismo modelo y prefijo. Si cambia la clave del modelo, falla la clave de caché: se pierde el descuento del 90 % y es habitual ahorrar con routing para devolverlo en fallos de caché. Por eso OpenRouter recomienda stickiness mediante <code>session_id</code>.</p>
<p>El patrón práctico es simple: no clasificar cada llamada, sino dividir estáticamente por tipo de tarea. El campo <code>model</code> de subagent sigue este patrón. Explore usa siempre una vía Haiku para explorar código; revisión usa una vía Opus. Dentro de cada vía se repiten modelo, prompt y herramientas, y cada modelo acumula su propia caché. Que «routing se vuelve estándar» significa cada vez más una bifurcación estática por tarea con «coordinador + ejecutor», no un clasificador por llamada. Así conviven routing y caché.</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="Gráfico publicado por Cursor de la puntuación CursorBench 3.1 frente al coste medio por tarea, con Composer 2.5 cerca de las mejores puntuaciones en el extremo más barato" width="1680" height="1120" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Es una vía distinta de ahorro, pero también puede usarse un agente como Cursor. <a href="https://cursor.com/blog/composer-2-5" target="_blank" rel="noopener noreferrer">Composer 2.5, modelo propio de Cursor publicado el 18 de mayo de 2026,</a> se basa en el checkpoint abierto Kimi K2.5 de Moonshot AI y fue ajustado para programación. Cursor afirma que en sus benchmarks alcanza un rendimiento comparable a Claude Opus 4.7 por cerca de una décima parte del precio. La tarifa publicada es $0.50 de entrada y $2.50 de salida, un orden de magnitud por debajo de $5.00 y $25.00 de Opus 4.8.</p>
<p>Como Cursor midió su propio modelo, es prudente no tomar las cifras absolutas como definitivas. Importa la tendencia: un modelo menor especializado en código puede rendir de forma comparable a uno de frontera general en la misma tarea y reducir el costo en un orden de magnitud. Es una de las tendencias de ahorro más claras de 2026.</p>
<p>Lo más interesante es que no solo baja la tarifa: <strong>cuando el modelo se diseña junto con un IDE, también reduce la propia entrada</strong>. Composer y Cascade están entrenados para consumir con eficiencia el contexto que envía el IDE —archivo actual, archivos vecinos y símbolos indexados—. Así se reduce la inflación causada cuando un modelo general pide más archivos y repite operaciones de grep y lectura. Baja un orden de magnitud la tarifa y también un poco la cantidad de tokens, por lo que el ahorro real puede superar la diferencia de precio.</p>
<h3 id="sacar-el-contexto-fuera-del-contexto"><a class="anchor" href="#sacar-el-contexto-fuera-del-contexto">Sacar el contexto fuera del contexto</a></h3>
<p>Una de las tendencias más interesantes de 2026 es cargar «solo lo necesario, cuando sea necesario». Anthropic lo llama <strong>contexto just in time (JIT)</strong>. En vez de incluir todos los recursos por adelantado, el agente conserva referencias ligeras como rutas o consultas y recupera el material mediante herramientas cuando lo necesita. Claude Code sigue este patrón al leer archivos bajo demanda con glob y grep en lugar de cargar toda la base de código.</p>
<p>memory tool y context editing, publicados por Anthropic con Sonnet 4.5, siguen la misma filosofía. La «memoria» de memory tool no es el historial de conversación que vive en la ventana y desaparece al cerrar la sesión, ni un archivo como <code>CLAUDE.md</code> escrito por una persona y cargado al inicio. memory tool es <strong>una herramienta con la que el modelo escribe y lee archivos directamente</strong>.</p>
<p>En detalle, las interfaces son sencillas. memory tool permite a Claude crear, leer, actualizar y borrar archivos en un directorio dedicado de la infraestructura del usuario. El modelo trata las notas como un sistema de archivos común. Alojarlo en la infraestructura del usuario es decisivo: ofrece memoria persistente fuera de la ventana entre sesiones y deja al usuario controlar almacenamiento y retención. La memoria de sesión es volátil dentro del contexto; esta permanece fuera. context editing actúa en dirección opuesta: al acercarse al límite, elimina resultados antiguos de herramientas que ya no se consultan, mantiene la conversación y permite ejecutar el agente más tiempo.</p>
<p>En la evaluación de Anthropic, ambas herramientas mejoraron el rendimiento un 39 % sobre la línea base y context editing por sí solo un 29 %. En una evaluación de búsqueda web de 100 turnos, el consumo cayó un 84 %. (Son benchmarks del proveedor, pero la dirección es clara: diseñar cómo vaciar el contexto importa cada vez más que llenarlo.) Esto coincide con la tubería de <code>/compact</code>: <code>/compact</code> reduce lo que está dentro; memory tool crea almacenamiento fuera. Se complementan.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p><img src="/content/260611/14.webp" alt="Ingeniería de prompts junto a ingeniería de contexto: una consulta de un turno lleva solo prompt de sistema y mensaje de usuario, mientras que un agente selecciona documentos, herramientas y memoria y realimenta los resultados" width="1680" height="946" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>En conjunto, el ahorro se reduce a tres ejes: <strong>enviar menos para el mismo trabajo, pagar menos por la misma entrada y asignar la misma respuesta a un modelo más barato</strong>. Prompt caching, Batch API, aislamiento con subagent, <code>/compact</code>, memory tool y context editing, model routing y modelos especializados solo difieren en qué eje atacan. La dirección de 2026 puede resumirse así: el centro de gravedad pasa de llenar el contexto a vaciarlo y seleccionarlo, es decir, a context engineering. Como muestra context rot, un contexto ligero mejora el costo y la precisión.</p>
<p>No existe una única respuesta correcta. Cada equipo trabaja distinto, e incluso para una misma persona la estructura de costos cambia al escribir prosa o código. Pero algo es seguro: con un ecosistema tan rápido, asumir que «el patrón que funcionó ayer funcionará hoy» resulta cada vez más arriesgado. Seguir modelos, herramientas y tarifas nuevas, y adquirir el hábito de validarlos con un pequeño POC en el propio flujo, quizá sea la forma de ahorro más discreta y duradera. Recomiendo a cada lector revisar al menos una vez su propia contabilidad de tokens. (Para entender la raíz de estas estrategias, conviene leer también BPE y KV cache en Cómo funcionan los tokens.)</p>
<p>Queda una pregunta. Si ya sabemos vaciar y seleccionar el contexto, ¿qué sigue? Los trabajos publicados desplazan el foco un paso más allá, hacia el sistema de agentes completo: cómo operarlo (diseño del harness), cómo medir si funcionó bien (eval) y cómo confinarlo cuando falla (containment). Curiosamente, cuando la actitud de «mídelo con un POC» de este artículo escala al sistema, se convierte exactamente en eval. Exploraré esa dirección en <a href="/260622" target="_blank" rel="noopener noreferrer">Más allá del contexto</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>IA</category>
            <category>Tokens</category>
        </item>
        <item>
            <title><![CDATA[Cómo funcionan los tokens]]></title>
            <link>https://hooninedev.com/es/260610</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260610</guid>
            <pubDate>Wed, 10 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero explicar qué son en realidad los tokens de IA y cómo funcionan. Hasta ahora he escrito sobre todo acerca de cómo aprovechar bien las herramientas de IA, cuáles están de moda y ...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero explicar qué son en realidad los tokens de IA y cómo funcionan.</p>
<p>Hasta ahora he escrito sobre todo acerca de cómo aprovechar bien las herramientas de IA, cuáles están de moda y por qué. Sin embargo, mientras preparaba el artículo sobre <a href="/260611" target="_blank" rel="noopener noreferrer">cómo ahorrar tokens</a>, volví a reparar en algo: para hablar de reducir costes, primero hay que entender «qué es un token y cómo se factura», pero nunca había explicado con rigor esa base. (Al escribir la guía de ahorro, la parte dedicada al funcionamiento de los tokens creció lo suficiente como para merecer un artículo propio.)</p>
<p>Este texto ofrece, por tanto, los fundamentos antes de entrar en las técnicas de ahorro. Veremos qué es exactamente un token si no es una palabra ni un carácter (BPE), en qué forma entra en el modelo (embedding), por qué la salida cuesta más que la entrada (prefill/decode), de qué parte del Transformer —la arquitectura de la red neuronal— nace el ahorro de prompt caching (KV cache) y por qué un contexto más largo resulta más caro (el coste cuadrático de la atención). Si buscas medidas prácticas, después puedes continuar con Cómo ahorrar tokens.</p>
<h2 id="un-token-no-es-una-palabra-ni-un-carácter"><a class="anchor" href="#un-token-no-es-una-palabra-ni-un-carácter">Un token no es una palabra ni un carácter</a></h2>
<p>Empecemos por el hecho más básico: <strong>un token no es una palabra ni un carácter.</strong> Es una unidad de un vocabulario que el modelo construye comprimiendo secuencias de caracteres frecuentes en los datos de entrenamiento.</p>
<p>Nuestra intuición al preguntar «¿cuántas palabras tiene esta frase?» no coincide con la forma en que un modelo divide el texto. El modelo agrupa como una sola unidad combinaciones de caracteres que suelen aparecer juntas y segmenta el texto entrante según ese vocabulario. Por eso una palabra puede corresponder a un único token o dividirse en varios.</p>
<p>El algoritmo que construye ese vocabulario es BPE, una base común a casi todos los LLM modernos.</p>
<h3 id="el-algoritmo-bpe"><a class="anchor" href="#el-algoritmo-bpe">El algoritmo BPE</a></h3>
<p>BPE (Byte Pair Encoding o codificación por pares de bytes) amplía un vocabulario fusionando repetidamente el par de símbolos adyacentes que aparece con mayor frecuencia en un símbolo nuevo.</p>
<p>Lo curioso es que no nació para el procesamiento del lenguaje natural. Philip Gage propuso BPE por primera vez en 1994 como técnica de <a href="/240706" target="_blank" rel="noopener noreferrer">compresión de datos</a>. El método sustituía el par de bytes más frecuente por un byte que no apareciera en los datos y almacenaba aparte, en una tabla, las reglas de sustitución.</p>
<p>El equipo de Sennrich, de la Universidad de Edimburgo, trasladó la idea al problema del vocabulario en la traducción neuronal. En «Neural Machine Translation of Rare Words with Subword Units», publicado en 2015 y presentado en ACL 2016, resolvieron con BPE la incapacidad de un vocabulario fijo para manejar palabras raras o desconocidas. La idea consiste en representar las palabras como combinaciones de fragmentos subword más pequeños en vez de memorizarlas enteras; así, incluso una palabra ausente del vocabulario puede codificarse con piezas conocidas.</p>
<p>El proceso es sorprendentemente sencillo.</p>
<ol>
<li>Al principio, cada carácter individual es un token.</li>
<li>Se busca en el corpus el par de tokens adyacentes más frecuente.</li>
<li>Se fusiona ese par en un token nuevo y se sustituyen por él todas sus apariciones en el corpus.</li>
<li>Se repiten los pasos 2 y 3 hasta alcanzar el tamaño de vocabulario objetivo.</li>
</ol>
<p>Así, aunque al principio cada carácter es un token, combinaciones frecuentes como «th», «the» o «tion» acaban fusionándose en tokens únicos. Cuanto más habitual es un patrón, más probable es que forme un bloque mayor.</p>
<p><img src="/content/260610/2.webp" alt="Cómo un tokenizer BPE amplía su vocabulario fusionando repetidamente pares de caracteres adyacentes frecuentes" style="max-width:min(500px, 100%);height:auto;" width="1024" height="1536" loading="eager" fetchpriority="high" decoding="async"></p>
<p>La familia GPT lleva la idea un paso más allá con <strong>BPE a nivel de bytes</strong>. Primero convierte el texto en un flujo de bytes UTF-8 y después aplica BPE. De este modo, el vocabulario base puede empezar con solo 256 entradas —una por cada byte posible— y aun así codificar cualquier texto representable en UTF-8 sin tokens desconocidos. Ya sean emojis, caracteres chinos o símbolos especiales, siempre existe al menos una representación a nivel de byte.</p>
<h3 id="el-mismo-texto-distinto-número-de-tokens"><a class="anchor" href="#el-mismo-texto-distinto-número-de-tokens">El mismo texto, distinto número de tokens</a></h3>
<p>Debido a esta estructura, un mismo texto puede producir cantidades de tokens muy distintas según el tokenizer, la herramienta que divide el texto escrito por personas en unidades que el modelo puede procesar. Según cómo se haya entrenado el vocabulario, una misma frase puede descomponerse en piezas más pequeñas o en bloques mayores.</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="Comparación del número de tokens del mismo texto según el tokenizer; o200k_base usa menos tokens que cl100k_base en textos no ingleses" width="1680" height="997" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Los datos que OpenAI publicó junto con GPT-4o muestran bien la diferencia. El nuevo tokenizer o200k_base de la familia GPT-4o representa el mismo texto con menos tokens que cl100k_base, empleado por GPT-4. En inglés es alrededor de 1,1 veces más eficiente, pero la brecha crece en otros idiomas. En los ejemplos breves publicados, el número de tokens bajó unas 1,4 veces en chino y japonés, 1,7 veces en coreano y hasta 2,9 veces en hindi. (Desde la perspectiva de quien usa coreano, resulta llamativo que el salto generacional del tokenizer se perciba mucho más que en inglés.)</p>
<p>En una perspectiva más amplia también hay diferencias entre idiomas. En el análisis de Yennie Jun con cl100k_base, una frase con el mismo significado casi siempre usa menos tokens en inglés, mientras que los idiomas con sistemas de escritura propios suelen necesitar más. El hindi y el bengalí pueden usar cinco veces más tokens que el inglés, y el birmano más de diez veces. El coreano y el chino también se tokenizan en más piezas que el inglés, aunque sin llegar a esos extremos.</p>
<p>De aquí se desprende una consecuencia práctica importante. Al introducir un nuevo tokenizer a partir de Opus 4.7, Anthropic indicó en su documentación oficial de precios que «el mismo texto puede facturarse con hasta un 35 % más de tokens que en modelos anteriores». <strong>Aunque el precio por token no cambie, la factura aumenta en la misma proporción que el número de tokens.</strong> Una comparación honesta entre modelos debe mirar no solo la tarifa, sino la tarifa multiplicada por el número de tokens previsto.</p>
<p>Pero estos tokens segmentados no entran en el modelo como caracteres o palabras literales. ¿Qué forma adopta entonces un token como entrada del modelo?</p>
<h2 id="cómo-entran-los-tokens-en-el-modelo"><a class="anchor" href="#cómo-entran-los-tokens-en-el-modelo">Cómo entran los tokens en el modelo</a></h2>
<p>Las redes neuronales no pueden trabajar directamente con texto. En último término, un modelo solo calcula con números; más exactamente, con vectores, es decir, conjuntos de números. Por eso, antes de entrar en el modelo, los tokens pasan por varias etapas que los convierten en valores numéricos.</p>
<p>El orden es el siguiente.</p>
<ol>
<li><strong>Texto → tokens</strong>: un tokenizer BPE divide el texto en fragmentos de token.</li>
<li><strong>Token → token ID</strong>: cada token se asigna a un ID entero que indica su posición en el vocabulario. Por ejemplo, si <code>" the"</code> es el token número 1.234, su ID será <code>1234</code>.</li>
<li><strong>Token ID → vector de embedding</strong>: el ID selecciona la fila correspondiente de la matriz de embeddings. Esta matriz es una tabla enorme de tamaño <code>vocabulary size × model dimension (d_model)</code>; cada token ID corresponde a una fila, es decir, a un vector. Podemos imaginar ese vector como unas coordenadas que contienen el «significado» del token.</li>
<li><strong>Añadir información posicional</strong>: por sí misma, la autoatención desconoce el orden de los tokens. Es decir, vería «yo te quiero» y «te yo quiero» como lo mismo. Por ello se inyecta información posicional en cada vector de token para comunicar el orden de la secuencia.</li>
</ol>
<p>Como la explicación resulta abstracta solo con palabras, he añadido una herramienta interactiva para experimentar con las cuatro etapas. Al cambiar la frase o pulsar un token, se puede seguir su recorrido desde el ID hasta el vector de embedding y ver cómo la información posicional produce el vector de entrada final. (Solo se muestran unas pocas dimensiones, pero conviene recordar que el vector real de GPT-4 tiene 12.288.)</p>
<p>La dimensión del modelo (d_model) es la longitud del vector que representa un token. En el artículo original del Transformer era 512, pero en los LLM actuales es mucho mayor. (Incluso Llama 2 7B usa 4.096 dimensiones.) Una dimensión mayor permite expresar con más riqueza diferencias sutiles de significado, a costa de más cálculo y memoria.</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="Cómo un token ID consulta una fila de la matriz de embeddings y se convierte en un vector de dimensión d_model" width="704" height="435" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>La forma de introducir información posicional también ha cambiado. El Transformer original sumaba a los embeddings vectores de posición construidos con funciones seno y coseno. RoPE (Rotary Position Embedding), estándar de facto en los LLM abiertos actuales, inyecta en cambio la posición relativa durante el cálculo de la atención al rotar los vectores Query y Key. (El mecanismo cambia, pero el objetivo es el mismo: dotar al Transformer de sentido del orden.)</p>
<p>En resumen, el flujo es <strong>texto → tokens → token IDs → vectores de embedding (+ información posicional) → entrada del Transformer</strong>. El «número de tokens» de una factura corresponde a la segunda etapa de este proceso: cuántos tokens produjo la segmentación del texto.</p>
<p>Ya sabemos cómo se crean los tokens y en qué forma entran en el modelo. Pero cuando este los procesa, ¿por qué cuestan tan distinto la entrada y la salida, y cómo puede abaratarse el envío repetido de una misma entrada?</p>
<h2 id="por-qué-la-entrada-es-barata-y-la-salida-es-cara"><a class="anchor" href="#por-qué-la-entrada-es-barata-y-la-salida-es-cara">Por qué la entrada es barata y la salida es cara</a></h2>
<p>Quien haya trabajado con tokens probablemente se lo haya preguntado: en las tablas de precios, <strong>los tokens de salida cuestan varias veces más que los de entrada.</strong> En Anthropic, la tarifa de salida es exactamente cinco veces la de entrada en todos los modelos. (Opus cuesta $5 de entrada y $25 de salida; Haiku, $1 y $5.) Si es el mismo token, ¿por qué cambia el precio según entre o salga?</p>
<p>La respuesta es que el modelo procesa los tokens de maneras completamente distintas en la entrada y la salida. La inferencia de un LLM tiene dos etapas.</p>
<ul>
<li><strong>prefill (procesamiento de la entrada)</strong>: procesa todos los tokens de la instrucción <strong>a la vez y en paralelo</strong>. Contenga 1.000 o 10.000 tokens, la GPU los recorre conjuntamente y calcula los vectores K/V (Key/Value) de cada uno. El cálculo total es considerable, pero el paralelismo mejora la eficiencia por token.</li>
<li><strong>decode (generación de la salida)</strong>: genera los tokens de la respuesta <strong>en secuencia, de uno en uno</strong>. Tras producir uno, lo añade a la entrada y genera el siguiente. El ciclo continúa hasta completar la respuesta.</li>
</ul>
<p>Esta asimetría crea la diferencia de coste. Según el análisis de rendimiento de inferencia de Databricks, prefill es una etapa limitada por el cálculo, mientras que decode está limitado por el ancho de banda de memoria. Durante decode, para producir cada token hay que volver a leer de la memoria de la GPU todos los pesos del modelo, y cada pasada solo genera un token. Gran parte de la enorme capacidad de cálculo de la GPU queda ociosa. (En palabras de Databricks, se paga por mantenerla funcionando sin utilizar el cálculo disponible.)</p>
<p>En otras palabras, <strong>los tokens de entrada se procesan eficientemente juntos mediante prefill paralelo, mientras que los de salida deben extraerse ineficientemente de uno en uno.</strong> Factores comerciales como la demanda o el margen también pueden influir en el precio, pero esta ineficiencia de decode forma parte de su fundamento técnico. «Recibir una respuesta corta» no es, por tanto, un consejo de ahorro genérico: reduce directamente la etapa más cara.</p>
<p>¿Hay forma de hacer que decode sea al menos un poco menos ineficiente? Aquí entra en juego KV cache.</p>
<h2 id="cómo-el-almacenamiento-en-caché-reduce-el-precio"><a class="anchor" href="#cómo-el-almacenamiento-en-caché-reduce-el-precio">Cómo el almacenamiento en caché reduce el precio</a></h2>
<p>Prompt caching guarda una vez la entrada estática en una caché para que las llamadas siguientes la lean a un precio mucho menor. Según la documentación oficial de Anthropic, una lectura cuesta 0,1 veces la tarifa base de entrada: exactamente un 10 %. La escritura cuesta 1,25 veces con un TTL (Time To Live, periodo de validez de la caché) de cinco minutos y 2 veces con un TTL de una hora. Se paga un poco más en la primera llamada y se ahorra un 90 % desde la segunda.</p>
<p>Pero ¿cómo puede reducirse el precio un 90 %? ¿Y por qué es tan estricta la condición de que «el prefijo coincida exactamente»? Ambas respuestas aparecen al mirar dentro del Transformer.</p>
<h3 id="kv-cache"><a class="anchor" href="#kv-cache">KV cache</a></h3>
<p>Al procesar los tokens de entrada, el modelo crea los vectores Query/Key/Value (Q/K/V) correspondientes. La autoatención calcula puntuaciones de atención mediante el producto escalar de Query y Key y usa esas puntuaciones para obtener una suma ponderada de los valores. Es el cálculo que determina cuánto debe «consultar» un token a los demás.</p>
<p>Aquí es donde decode plantea el problema descrito antes. Para crear cada nuevo token, el modelo necesita los K/V de todos los tokens anteriores. Recalcularlos desde cero en cada paso repetiría indefinidamente el mismo trabajo.</p>
<p><img src="/content/260610/1.webp" alt="Estructura de funcionamiento del KV cache del Transformer" width="1536" height="1024" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>KV cache elimina esa duplicación. Almacena los K/V ya calculados y, al generar un token nuevo, lee y reutiliza desde la caché los K/V anteriores en lugar de volver a calcularlos. Dentro de una secuencia, el coste de producir un token adicional pasa así de O(n²), que recalcularía toda la secuencia en cada paso, a O(n), que solo lee la caché. (En rigor, hablamos del coste por paso; la idea esencial es sencilla: no recalcular lo ya calculado.) Esta es también la razón por la que decode está limitado por la memoria: cada paso debe leer de nuevo el KV cache desde la memoria.</p>
<h3 id="reutilización-entre-llamadas"><a class="anchor" href="#reutilización-entre-llamadas">Reutilización entre llamadas</a></h3>
<p>Si KV cache nació para acelerar decode dentro de una respuesta, prompt caching propone reutilizar esa estructura de memoria <strong>no solo dentro de una llamada, sino también entre una llamada y la siguiente</strong>.</p>
<p>Los K/V de un prefijo estático —una instrucción del sistema, definiciones de herramientas o fragmentos de código cuyo comienzo cambia poco— permanecen en la memoria de la GPU durante un TTL de cinco minutos o una hora. Si la siguiente llamada empieza con el mismo prefijo, el modelo omite por completo el cálculo de sus K/V y los lee directamente de la caché. En sentido estricto, la tarifa de los tokens de entrada no baja por arte de magia: <strong>desaparece el trabajo de GPU necesario para calcularlos.</strong> La reducción del 90 % refleja ese cálculo evitado.</p>
<p>Al entender el mecanismo, también se entiende la exigencia de coincidencia exacta del prefijo. Un acierto de caché se decide comparando el hash acumulativo del prefijo. Como la autoatención es causal, cambiar un solo token anterior modifica los K/V de todos los tokens posteriores. Basta con añadir una marca temporal al principio de la instrucción para que su valor cambie en cada llamada, el hash deje de coincidir y toda la caché posterior quede invalidada.</p>
<p>Anthropic lee la caché con la jerarquía <code>tools</code> → <code>system</code> → <code>messages</code>. Por eso, cambiar una sola definición de herramienta al principio invalida toda la caché que viene detrás. La conclusión es simple: <strong>el contenido estático debe ir primero y el contenido dinámico que cambia en cada llamada, al final.</strong></p>
<p>La caché también tiene restricciones menos conocidas. El mínimo de tokens que admite depende del modelo e incluso de la versión dentro de una misma familia. Una instrucción más corta que el umbral no se guarda, sin aviso, aunque el almacenamiento en caché esté activado. (Como esos mínimos y las políticas de cada proveedor afectan directamente al diseño de costes, los explico con las tablas de precios en la guía de ahorro de tokens.)</p>
<h2 id="por-qué-la-ventana-de-contexto-impone-un-límite-de-tokens"><a class="anchor" href="#por-qué-la-ventana-de-contexto-impone-un-límite-de-tokens">Por qué la ventana de contexto impone un límite de tokens</a></h2>
<p>El último concepto imprescindible es la ventana de contexto: el número máximo de tokens que un modelo puede aceptar a la vez. Entender el motivo de este límite también aclara la conocida advertencia de que «un contexto más largo cuesta más».</p>
<p>La clave está en la estructura de cálculo de la autoatención. La atención hace que cada token consulte una vez a todos los demás. Con n tokens hay n × n pares, por lo que <strong>el cálculo y la memoria de la autoatención crecen con el cuadrado de la longitud n de la secuencia.</strong> Al duplicar los tokens, el coste de la atención se cuadruplica. (Conviene precisar que no todo el modelo es O(n²); lo es la etapa de atención.)</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="Matriz de autoatención en la que cada token consulta a todos los demás; con n tokens, los pares crecen como n×n" width="330" height="304" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Las <a href="https://huggingface.co/docs/transformers/en/llm_tutorial_optimization" target="_blank" rel="noopener noreferrer">estimaciones de Hugging Face</a> muestran lo acusada que es esta subida cuadrática. Con atención estándar sin optimizar, almacenar solo la matriz de puntuaciones requiere unos 50 MB para una entrada de 1.000 tokens, cerca de 19 GB para 16.000 tokens y casi 1 TB para 100.000.</p>
<p>El KV cache también ocupa memoria en proporción al número de tokens. Conforme crece el contexto, los K/V que hay que conservar se acumulan linealmente. En definitiva, el límite de la ventana de contexto es una frontera física fijada por el modelo: más allá, la memoria y el cálculo dejan de ser manejables.</p>
<p>Por eso, arrastrar sin cambios una conversación larga no consiste solo en sumar tokens de entrada. El coste de la atención crece al cuadrado y, al acercarse al límite, también disminuye la precisión con que el modelo maneja la información. (Esta pérdida de precisión está directamente relacionada con la degradación del contexto, tratada en la guía de ahorro; mantener un contexto ligero beneficia tanto al coste como a la calidad de las respuestas.)</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En conjunto, el funcionamiento de los tokens se resume en unos pocos hechos. <strong>Un token es una unidad de vocabulario que BPE crea agrupando patrones frecuentes</strong>; tras asignarse a un token ID, se convierte en un vector de embedding y entra en el modelo. <strong>La entrada se procesa de una vez mediante prefill paralelo, mientras que la salida es más cara porque debe extraerse token a token</strong>; KV cache reduce esa ineficiencia reutilizando los K/V. <strong>La caché no abarata por magia, sino porque el Transformer evita recalcular K/V ya obtenidos</strong>, y de esta estructura se deriva la estricta condición de que el prefijo sea idéntico. Por último, un contexto más largo cuesta más porque el coste de la atención crece con el cuadrado del número de tokens.</p>
<p>Con esta base, la economía de los tokens se ve con mucha más claridad. Acortar la salida, organizar la instrucción para no romper la caché, mantener ligero el contexto o pasar a un modelo más barato cuando ofrece la misma respuesta son estrategias asentadas en estos principios. Si has llegado hasta aquí para entender el mecanismo, te recomiendo seguir con la guía de ahorro de tokens y comprobar cómo estas ideas reducen los costes reales.</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>IA</category>
            <category>Tokens</category>
        </item>
        <item>
            <title><![CDATA[Archivos de contexto]]></title>
            <link>https://hooninedev.com/es/260529</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260529</guid>
            <pubDate>Fri, 29 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta publicación quiero hablar sobre los archivos de contexto que leen los agentes de codificación con IA. Este artículo está pensado para quienes tienen CLAUDE.md, AGENTS.md, SKILL.md y .cursor/ru...]]></description>
            <content:encoded><![CDATA[<p>En esta publicación quiero hablar sobre <strong>los archivos de contexto que leen los agentes de codificación con IA</strong>.</p>
<p>Este artículo está pensado para quienes tienen <code>CLAUDE.md</code>, <code>AGENTS.md</code>, <code>SKILL.md</code> y <code>.cursor/rules</code> acumulados en un mismo proyecto y no tienen claro cuándo se lee cada uno ni qué escribir en él. Al terminar, conocerás las diferencias entre estos archivos, cómo los cargan los agentes y un criterio respaldado por un estudio de ETH Zurich: escribir solo la información que el agente no puede inferir.</p>
<p>Trabajo como desarrollador frontend y utilizo Claude a diario. Con el tiempo, apareció un <code>CLAUDE.md</code> en la raíz del proyecto, junto a un <code>AGENTS.md</code> que alguien había creado; <code>.cursorrules</code> seguía olvidado en un rincón y yo mismo terminé creando una carpeta <code>.claude/skills/</code> siguiendo algún artículo que había visto. (Cuando me di cuenta, había unos cinco archivos con contenidos parecidos).</p>
<h2 id="agentes-sin-memoria-persistente"><a class="anchor" href="#agentes-sin-memoria-persistente">Agentes sin memoria persistente</a></h2>
<p>Los agentes de codificación con IA tienen una limitación fundamental: <strong>carecen de memoria persistente</strong>. Cada sesión empieza desde cero y, en la conversación siguiente, no recuerdan las convenciones acordadas ayer ni la estructura de carpetas explicada una hora antes. Los archivos de contexto son el mecanismo más sencillo para resolver este problema. Si colocamos en el proyecto un archivo que se lea automáticamente al comenzar cada sesión, no tendremos que repetir siempre las mismas explicaciones.</p>
<p>El problema es que cada herramienta creó por separado archivos basados en la misma idea. Claude Code lee <code>CLAUDE.md</code>; Cursor, <code>.cursorrules</code> (actualmente deprecated y sustituido por la recomendación de usar <code>.cursor/rules</code>); GitHub Copilot, <code>.github/copilot-instructions.md</code>; y OpenAI Codex, <code>AGENTS.md</code>. Si un equipo utiliza varias herramientas, termina copiando el mismo contenido en cuatro lugares distintos.</p>
<h3 id="claudemd"><a class="anchor" href="#claudemd">CLAUDE.md</a></h3>
<p><code>CLAUDE.md</code> es un archivo que Claude Code lee automáticamente al iniciar una sesión. Según la documentación oficial de Anthropic (<code>code.claude.com/docs/en/memory</code>), Claude Code busca <code>CLAUDE.md</code> en tres niveles.</p>
<ul>
<li><strong>Memoria de usuario</strong> (<code>~/.claude/CLAUDE.md</code>): valores globales predeterminados que se aplican a todos los proyectos de la máquina</li>
<li><strong>Memoria del proyecto</strong> (<code>CLAUDE.md</code> en la raíz del proyecto): se incluye en git y la comparte todo el equipo</li>
<li><strong>Memoria local</strong> (<code>CLAUDE.md</code> en un subdirectorio): solo se carga adicionalmente al trabajar en ese directorio</li>
</ul>
<p>Cuando existen los tres niveles, Claude <strong>los lee todos y los concatena (concatenate)</strong>. No elige uno solo según su prioridad, sino que los más específicos se añaden encima, como en el cascade de CSS. (Se combinan; no se reemplazan). Por tanto, distribuir reglas sobre un mismo tema entre varios niveles puede generar conflictos. (La documentación oficial de Anthropic indica que el comportamiento no está garantizado cuando hay conflictos).</p>
<p>Hay un detalle que suele pasarse por alto: <strong>lee todos los <code>CLAUDE.md</code> que encuentra al ascender desde el directorio de trabajo actual hasta la raíz del repositorio</strong>. Así, si trabajamos dentro de <code>packages/ui/</code> en un monorepo, se cargan tanto el <code>CLAUDE.md</code> de la raíz como <code>packages/ui/CLAUDE.md</code>. (Es una capacidad potente, pero también implica que el contexto puede crecer sin que nos demos cuenta).</p>
<h3 id="agentsmd"><a class="anchor" href="#agentsmd">AGENTS.md</a></h3>
<p><code>AGENTS.md</code> es un estándar creado para resolver la proliferación de archivos específicos de cada herramienta que acabamos de describir. En diciembre de 2025, OpenAI donó este estándar a la <strong>Agentic AI Foundation (AAIF)</strong> de la Linux Foundation. Según el <a href="https://www.linuxfoundation.org/press/linux-foundation-announces-the-formation-of-the-agentic-ai-foundation" target="_blank" rel="noopener noreferrer">anuncio de la Linux Foundation</a>, la AAIF la cofundaron Anthropic, Block y OpenAI, y sus proyectos fundacionales son tres: MCP de Anthropic (el protocolo que conecta a los agentes con sistemas externos), goose de Block y <code>AGENTS.md</code> de OpenAI. El sitio oficial (<code>agents.md</code>) afirma que <strong>más de 60.000 repositorios open source han adoptado este archivo</strong>.</p>
<p>La lista de herramientas compatibles lo deja aún más claro. 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 y Augment Code, entre muchas otras, lo admiten. GitHub Copilot empezó a ofrecer compatibilidad nativa con <code>AGENTS.md</code> en agosto de 2025. Un detalle interesante es que <strong>la compatibilidad nativa de Claude Code con <code>AGENTS.md</code> sigue siendo una active feature request</strong>. Claude Code continúa tratando <code>CLAUDE.md</code> como su archivo principal.</p>
<p>Aunque se lo llame estándar, cabe dudar de si realmente se está adoptando. La prueba más sólida es el <strong>dogfooding</strong> (usar uno mismo el estándar que ha creado).</p>
<ul>
<li>En la raíz de la rama canary de <strong>Vercel/Next.js</strong> hay un <code>AGENTS.md</code>. En realidad, es un enlace simbólico a <code>CLAUDE.md</code>, que contiene la estructura del monorepo, iteraciones de 1-2 segundos mediante <code>pnpm --filter=next dev</code>, guías para probar tanto Turbopack como Webpack, el script <code>pr-status</code> y reglas para gestionar variables de entorno y secretos. El hecho de que <code>create-next-app</code> haya pasado a generar juntos <code>AGENTS.md</code> y <code>CLAUDE.md</code> en los proyectos nuevos responde a la misma tendencia.</li>
<li>El propio repositorio <strong>OpenAI/codex</strong> mantiene su propio <code>AGENTS.md</code>.</li>
</ul>
<p>En términos estratégicos, una práctica se está consolidando como la opción habitual: <strong>usar <code>AGENTS.md</code> como única fuente de verdad (single source of truth)</strong> y reducir <code>CLAUDE.md</code> a una línea que haga referencia a <code>AGENTS.md</code>, más las instrucciones exclusivas de Claude Code. Así desaparece la duplicación y no se pierde nada, porque Claude Code lee ambos archivos.</p>
<h3 id="skillmd"><a class="anchor" href="#skillmd">SKILL.md</a></h3>
<p><code>SKILL.md</code> tiene una naturaleza distinta de los dos archivos anteriores. Si <code>CLAUDE.md</code> y <code>AGENTS.md</code> son <strong>instrucciones persistentes que siempre están presentes en el contexto</strong>, una Skill es <strong>una capacidad on-demand que solo se invoca cuando hace falta</strong>.</p>
<p>Las Skills se organizan por carpetas. Cada carpeta contiene un <code>SKILL.md</code>, los scripts que ejecuta la Skill y documentos Markdown adicionales. Claude solo carga esa carpeta cuando la tarea actual coincide con la <code>description</code> de la Skill. Esto se denomina <strong>progressive disclosure (divulgación progresiva)</strong>, un concepto que Jakob Nielsen formuló en el campo de la UX en 1995: trasladar las funciones avanzadas o poco frecuentes a pantallas secundarias para que el usuario se concentre en una sola tarea cada vez, reduciendo la carga cognitiva y los errores. En el contexto de Claude Skills, designa el mecanismo de «incorporar al contexto el contenido de una Skill únicamente cuando se necesita». Como resultado, el coste de la ventana de contexto puede reducirse de forma drástica.</p>
<p>El frontmatter de <code>SKILL.md</code> contiene varios campos propios.</p>
<ul>
<li><strong><code>description</code></strong>: explica en qué situaciones se necesita la Skill y actúa como trigger para que el modelo decida si debe invocarla</li>
<li><strong><code>allowed-tools</code></strong>: limita las herramientas que pueden utilizarse dentro de la Skill (por ejemplo, <code>"Read, Glob, Grep, Bash(python:*)"</code>)</li>
<li><strong><code>disable-model-invocation: true</code></strong>: impide que el modelo la invoque; solo el usuario puede activarla mediante un comando slash. Se usa para operaciones con efectos secundarios, como deployments o commits</li>
<li><strong><code>user-invocable: false</code></strong>: no aparece en el menú slash del usuario y solo Claude puede invocarla autónomamente como conocimiento de fondo</li>
</ul>
<p>Claude Skills se lanzó simultáneamente en Claude.ai, Claude Code, API y Agent SDK el 16 de octubre de 2025. El 18 de diciembre de 2025, Anthropic presentó la propia especificación de Skills como estándar abierto (<code>agentskills.io</code>). Simon Willison llegó a afirmar: «<strong>Skills are awesome, maybe a bigger deal than MCP</strong>». La razón es que su formato es mucho más sencillo que MCP y, al mismo tiempo, resuelve mediante progressive disclosure el problema del coste de la ventana de contexto.</p>
<p>MCP (Model Context Protocol), con el que aquí se comparaban las Skills, es un protocolo estándar que conecta al agente con sistemas externos como Slack, GitHub o una base de datos para que pueda invocarlos. Si los archivos de contexto tratan de qué contarle al agente, MCP trata de qué permitirle hacer. En qué se diferencia MCP de function calling lo he explicado aparte en <a href="/260524" target="_blank" rel="noopener noreferrer">MCP y function calling</a>.</p>
<p>Las herramientas que reducen el coste de que un agente encuentre el código relevante (Repomix, Aider, CodeGraph, Serena) reducen costes distintos según lo a fondo que entiendan el código. Esa comparación la he recogido aparte en <a href="/260526" target="_blank" rel="noopener noreferrer">Cuatro niveles de inteligencia de código</a>.</p>
<h3 id="archivos-de-otras-herramientas"><a class="anchor" href="#archivos-de-otras-herramientas">Archivos de otras herramientas</a></h3>
<p>El archivo <code>.cursorrules</code> de Cursor está <strong>deprecated desde la versión 0.43</strong>. La recomendación oficial actual consiste en colocar varios archivos dentro del directorio <code>.cursor/rules/</code>; esos archivos usan la extensión <code>.mdc</code>. Cada archivo <code>.mdc</code> tiene frontmatter YAML.</p>
<ul>
<li><strong><code>description</code></strong>: referencia que usa el agente para determinar la pertinencia de la regla</li>
<li><strong><code>globs</code></strong>: adjunta automáticamente la regla (auto-attach) cuando se incluye en la conversación un archivo que coincide con el patrón</li>
<li><strong><code>alwaysApply</code></strong>: si es <code>true</code>, se incluye siempre en todas las conversaciones (y se ignora <code>globs</code>)</li>
</ul>
<p>GitHub Copilot ha evolucionado en una dirección similar. Las instrucciones globales del repositorio se colocan en <code>.github/copilot-instructions.md</code>; cuando se necesita scope por ruta, se crean archivos <code>.github/instructions/*.instructions.md</code> y se especifica un glob mediante la clave <code>applyTo:</code> del frontmatter. (Copilot code review ofrece compatibilidad oficial con path-scoped instructions desde septiembre de 2025).</p>
<p>Las demás herramientas, además de Cursor y Copilot, convergen en patrones parecidos. Podemos resumirlas así.</p>
<table>
<thead>
<tr>
<th>Herramienta</th>
<th>Archivo/directorio</th>
<th>Características</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Claude Code</strong></td>
<td><code>CLAUDE.md</code> (3 niveles)</td>
<td>Se combina siguiendo el árbol de directorios</td>
</tr>
<tr>
<td><strong>Cursor</strong></td>
<td><code>.cursor/rules/*.mdc</code></td>
<td>Scope de patrones de archivo mediante <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>Compatible con el glob <code>applyTo</code></td>
</tr>
<tr>
<td><strong>Cline</strong></td>
<td>Directorio <code>.clinerules/</code></td>
<td>Integra todos los <code>.md</code>/<code>.txt</code>; activación condicional mediante el glob <code>paths</code></td>
</tr>
<tr>
<td><strong>Continue.dev</strong></td>
<td><code>.continue/rules/*.md</code></td>
<td>Frontmatter <code>name</code>/<code>globs</code>/<code>alwaysApply</code></td>
</tr>
<tr>
<td><strong>Aider</strong></td>
<td><code>CONVENTIONS.md</code> + <code>.aider.conf.yml</code></td>
<td>Se incluye en cada solicitud; <strong>se recomiendan 200 líneas como máximo</strong></td>
</tr>
<tr>
<td><strong>Windsurf</strong></td>
<td><code>.windsurfrules</code> + <code>global_rules.md</code></td>
<td>Dos niveles: global y proyecto</td>
</tr>
<tr>
<td><strong>Estándar</strong></td>
<td><code>AGENTS.md</code> (AAIF)</td>
<td>Adoptado por más de 60.000 repositorios</td>
</tr>
</tbody>
</table>
<p>Resulta especialmente interesante el archivo <strong><code>CONVENTIONS.md</code> de Aider</strong>. Como la documentación oficial indica que el archivo completo se incorpora al contexto en cada solicitud, recomienda explícitamente <strong>«mantenerlo por debajo de 200 líneas»</strong>. (En cierto modo, Aider detectó pronto esta limitación y la comunica claramente a sus usuarios).</p>
<h3 id="memorymd"><a class="anchor" href="#memorymd">MEMORY.md</a></h3>
<p>Al margen de los archivos anteriores, hay otro patrón que aparece cada vez con más frecuencia: <code>MEMORY.md</code>. No es un estándar oficial, sino una convención surgida orgánicamente en la comunidad para <strong>registrar decisiones y errores a lo largo del tiempo</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>Si <code>CLAUDE.md</code> o <code>AGENTS.md</code> contienen <strong>las reglas vigentes en el presente</strong>, <code>MEMORY.md</code> recoge <strong>la historia de por qué se crearon esas reglas</strong>. (Son complementarios, no sustitutos).</p>
<h3 id="cómo-leen-estos-archivos-los-agentes"><a class="anchor" href="#cómo-leen-estos-archivos-los-agentes">Cómo leen estos archivos los agentes</a></h3>
<p>Hasta ahora hemos repasado qué archivos existen. Sin embargo, hay una pregunta que se omite con sorprendente frecuencia: <strong>¿dónde y cómo leen exactamente estos archivos los agentes?</strong> Comprender este mecanismo permite entender mucho mejor el resultado de ETH Zurich que veremos después: que los archivos de contexto no se siguen demasiado bien.</p>
<p>Empecemos por un hecho esencial. <strong><code>CLAUDE.md</code> no se inyecta como system prompt, sino como user message.</strong> La documentación oficial de Anthropic lo explica de la siguiente manera.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>El contenido de CLAUDE.md se entrega como un mensaje del usuario después del system prompt, no como parte del propio system prompt. Claude lo lee e intenta seguirlo, pero no hay garantía de que lo cumpla estrictamente.</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>En otras palabras, no es una regla obligatoria, sino «contexto de referencia». La guía oficial recomienda utilizar mecanismos separados, como un hook <code>PreToolUse</code>, cuando se quiere imponer un comportamiento concreto.</p>
<p>El orden de carga se acumula de broad → specific. En concreto: managed policy (configuración de la organización) → configuración global del usuario (<code>~/.claude/CLAUDE.md</code>) → proyecto (<code>./CLAUDE.md</code>) → configuración local (<code>./CLAUDE.local.md</code>). Dentro del mismo directorio, primero se carga <code>CLAUDE.md</code> y después <code>CLAUDE.local.md</code>. Podemos aprovechar que <strong>las instrucciones más cercanas se leen al final</strong> para que las reglas más específicas tengan mayor peso gracias al recency bias de los LLM.</p>
<p>Aquí entra en juego la interesante sintaxis <code>@import</code>. Si se escribe <code>@path/to/file</code> en cualquier parte del contenido de CLAUDE.md, el archivo correspondiente se despliega en ese lugar y se carga junto con él. <strong>La profundidad recursiva máxima es de 4 hops</strong>, y las rutas relativas se resuelven tomando como referencia el archivo que contiene el import. Por eso, la recomendación oficial consiste en tender un puente mediante <code>@AGENTS.md</code>. Si dejamos <code>CLAUDE.md</code> prácticamente vacío y solo escribimos <code>@AGENTS.md</code>, Claude Code leerá AGENTS.md de forma natural. (Es la solución más limpia mientras CLAUDE.md todavía no admita AGENTS.md de manera nativa).</p>
<p>También conviene considerar los tokens. CLAUDE.md no tiene un límite explícito de tokens, por lo que <strong>se carga entero si existe</strong>. Sin embargo, la recomendación oficial es <strong>no superar las 200 líneas por archivo</strong>. Cuando se superan las 200 líneas, la documentación advierte: «consume more context and may reduce adherence». Es interesante que, en Claude 4.x, <strong>el mero hecho de activar tool use añade automáticamente 346 tokens al special system prompt</strong> (con <code>tool_choice: auto</code>). El contexto se consume casi sin que nos demos cuenta.</p>
<p>Cursor emplea otro método. Las reglas de <code>.cursor/rules/*.mdc</code> funcionan en cuatro modalidades.</p>
<ul>
<li><strong>Always Apply</strong>: se incluye siempre en todos los chats; ignora globs/description</li>
<li><strong>Apply Intelligently</strong> (Agent Requested): el agente lee <code>description</code>, evalúa su pertinencia e incorpora la regla</li>
<li><strong>Apply to Specific Files</strong> (Auto Attached): se activa cuando entra en el contexto un archivo que coincide con el patrón glob</li>
<li><strong>Apply Manually</strong>: el usuario la invoca explícitamente mediante <code>@rule-name</code></li>
</ul>
<p>Las demás herramientas siguen otros enfoques. OpenAI Codex recorre desde la raíz del repositorio git hasta cwd, reúne todos los <code>AGENTS.md</code> y los inyecta <strong>justo antes del prompt del usuario</strong>. GitHub Copilot inserta <code>.github/copilot-instructions.md</code> con una prioridad intermedia en la ventana de contexto: «después de edit context y explicit references, pero antes de loosely related open files». Incluso con un mismo archivo <code>AGENTS.md</code>, el momento de carga, la prioridad y las reglas de merge difieren según la herramienta, por lo que <strong>no hay garantía de que las tres lo interpreten exactamente del mismo modo</strong>.</p>
<p>Queda, sin embargo, una pregunta fundamental: <strong>¿por qué los modelos solo siguen parte de las instrucciones presentes en el contexto?</strong> Decir simplemente que «las instrucciones son largas» no basta. Detrás de este fenómeno hay una limitación estructural de los LLM.</p>
<h3 id="alucinaciones-y-olvido-del-contexto"><a class="anchor" href="#alucinaciones-y-olvido-del-contexto">Alucinaciones y olvido del contexto</a></h3>
<p>Si alguna vez hemos visto a un agente de IA confundir el contexto de una conversación u olvidar al final algo que se le había indicado claramente al principio, hemos presenciado una forma de <strong>alucinación (Hallucination)</strong>. En general, al hablar de alucinaciones pensamos primero en «inventar hechos inexistentes», pero académicamente se dividen en tres categorías. El estudio de 2023 de Yue Zhang et al. («Siren's Song in the AI Ocean») distingue entre <strong>conflicto con la entrada</strong> (generar algo distinto de lo indicado explícitamente por el usuario), <strong>conflicto con el contexto</strong> (contradecir algo generado anteriormente por el propio modelo) y <strong>conflicto factual</strong> (no coincidir con el conocimiento del mundo). Ignorar instrucciones de un archivo de contexto no corresponde al tercer tipo, sino <strong>al primero</strong>: al procesar la entrada, el modelo trata parte de la información como si no existiera.</p>
<p>El problema más profundo es que estas alucinaciones son <strong>imposibles de eliminar por completo</strong>. Un equipo de la National University of Singapore lo demostró matemáticamente mediante teoría del aprendizaje. Ningún LLM puede aprender todas las funciones computables; por tanto, mientras se utilice como solucionador de problemas general, inevitablemente alucinará en algún punto.</p>
<p>El efecto de la posición también es importante. Un equipo de Stanford demostró experimentalmente que los modelos consultan mejor la información pertinente cuando está <strong>al principio o al final de la ventana de contexto</strong>, y que el rendimiento desciende notablemente cuando queda <strong>enterrada en el medio</strong>. Esto se relaciona directamente con los archivos de contexto. <code>CLAUDE.md</code> queda insertado en algún punto intermedio del orden de carga y, cuanto más se alarga la conversación, más se desplazan sus instrucciones hacia «el medio» del contexto. Es la otra cara del recency bias ya mencionado: la franja intermedia del <strong>efecto primacy-recency es la más vulnerable</strong>.</p>
<p>Al reunir todos estos fenómenos, aparece una imagen clara. Un archivo de contexto no es más que <strong>texto insertado fuera del sistema antes del primer turno del usuario</strong>. No obliga al modelo a tomar decisiones; es simplemente otro bloque de tokens que cae en la ventana de contexto. Cuanto más largo es el archivo y más se prolonga la conversación, más se desplazan sus instrucciones hacia «el medio» y menor es la probabilidad de que se consulten. Los resultados de ETH Zurich cuantifican esta limitación estructural.</p>
<h3 id="el-estudio-de-eth-zurich"><a class="anchor" href="#el-estudio-de-eth-zurich">El estudio de ETH Zurich</a></h3>
<p>Mucha gente habrá pensado: «Entonces, ¿lo mejor será escribir todo lo posible en estos archivos?». Sin embargo, un estudio reciente contradice frontalmente esta intuición: el trabajo de ETH Zurich que hemos venido mencionando.</p>
<p>El equipo de ETH Zurich publicó en febrero de 2026 el artículo «Evaluating AGENTS.md: Are Repository-Level Context Files Helpful for Coding Agents?». Midió cuatro agentes —Claude Code (Sonnet-4.5), Codex (GPT-5.2 / GPT-5.1 mini) y Qwen Code— usando un benchmark de 138 tareas reales de ingeniería de software en Python (AGENTBENCH) y SWE-bench Lite. Los resultados fueron sorprendentes.</p>
<ul>
<li>Los <strong>archivos de contexto generados automáticamente por un LLM</strong> redujeron la tasa de éxito de las tareas aproximadamente un 0,5 % en SWE-bench Lite y un 2 % en AGENTBENCH</li>
<li>Incluso los <strong>archivos escritos por personas</strong> solo lograron, de media, una mejora marginal cercana al 4 %</li>
<li>Añadir archivos de contexto incrementó el <strong>coste de inferencia por instancia en más de un 20 %</strong></li>
<li>En modelos más potentes (GPT-5.2), el efecto de los archivos de contexto fue aún menor (cuanto más potente es el modelo, mayor es su conocimiento paramétrico y más probable que el contexto adicional actúe como ruido)</li>
</ul>
<p>Hubo, no obstante, una excepción: <strong>especificar herramientas no estándar</strong>. Por ejemplo, al mencionar en el contexto <code>uv</code>, el gestor de paquetes de Python, la frecuencia con la que el agente utilizaba <code>uv</code> pasó de 0,01 a 1,6 veces por instancia: <strong>un aumento aproximado de 160 veces</strong>.</p>
<p>La recomendación de Aider de «no superar las 200 líneas» ofrece una orientación práctica basada en que «el archivo entra siempre en el contexto», mientras que el estudio de ETH Zurich demuestra cuantitativamente que «los archivos de contexto largos reducen estadísticamente el rendimiento». A mi juicio, sus implicaciones prácticas son las siguientes.</p>
<ul>
<li>Los <strong>archivos de contexto enormes generados automáticamente pueden hacer más daño que bien</strong>. Si metemos a la fuerza estándares de código, arquitectura y workflows en un <code>CLAUDE.md</code> de 300 líneas, el agente seguirá algunas partes e ignorará el resto. Esa inconsistencia puede producir resultados peores que no tener contexto.</li>
<li><strong>Lo que sí debemos escribir es «información que no puede inferirse»</strong>: herramientas no estándar, convenciones propias del proyecto y fallos anteriores. El modelo ya conoce las buenas prácticas generales de programación.</li>
<li>Conviene usar AGENTS.md como fuente única, reservar CLAUDE.md para instrucciones breves específicas de la herramienta y separar los workflows detallados en Skills.</li>
</ul>
<h2 id="en-conclusión"><a class="anchor" href="#en-conclusión">En conclusión</a></h2>
<p>Los archivos de contexto cambian de una herramienta a otra en nombre, ubicación y momento de lectura. Solo en este artículo hemos visto CLAUDE.md, AGENTS.md, SKILL.md, Cursor rules, copilot-instructions.md y MEMORY.md, y qué herramienta admite qué archivo no deja de cambiar. Memorizar una lista de archivos se queda obsoleto enseguida.</p>
<p>Por eso, mi objetivo aquí no era recomendar un formato de archivo concreto, sino desarrollar <strong>la capacidad de ver cómo leen los agentes estos archivos</strong>. Cuando sabemos por qué CLAUDE.md se inyecta como user message, por qué cada herramienta lee de forma distinta el mismo AGENTS.md y por qué las instrucciones se debilitan en la mitad del contexto, al aparecer un formato nuevo de archivo de contexto podemos identificar rápidamente «cuándo se carga y con cuánta fuerza actúa».</p>
<p>Al final, queda una intuición transmitida por el estudio de ETH Zurich: <strong>el modelo ya sabe muchas cosas</strong>. Meter de todo a la fuerza en un archivo de contexto no hace que el agente lo siga mejor. Es preferible conservar solo aquello que el modelo probablemente desconozca —convenciones propias del proyecto, herramientas no estándar y errores del pasado— y retirar lo demás. Escribir un archivo de contexto largo y escribirlo bien son dos problemas diferentes.</p>
<p>A quienes lean este artículo les recomiendo que, antes de ampliar CLAUDE.md a cientos de líneas, investiguen al menos una vez cuándo, dónde y con cuánta fuerza leen ese archivo las herramientas que ya utilizan. Creo que esa comprensión constituye una base sólida, cambien como cambien los formatos de archivo.</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>IA</category>
            <category>Herramientas-de-desarrollo</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[Cuatro niveles de inteligencia de código]]></title>
            <link>https://hooninedev.com/es/260526</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260526</guid>
            <pubDate>Tue, 26 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta entrada quiero hablar de en qué se diferencian entre sí las herramientas que reducen el costo de que un agente de programación con IA encuentre el código relevante. Este artículo es para desar...]]></description>
            <content:encoded><![CDATA[<p>En esta entrada quiero hablar de <strong>en qué se diferencian entre sí las herramientas que reducen el costo de que un agente de programación con IA encuentre el código relevante</strong>.</p>
<p>Este artículo es para desarrolladores que han visto a un agente gastar tokens repitiendo grep y lecturas de archivos en una base de código grande, y que dudan qué herramienta añadir, como Repomix, CodeGraph o Serena. Estas herramientas se dividen en cuatro niveles según lo profundamente que entienden el código (context packing, el repo map de tree-sitter, el grafo de conocimiento y LSP), y cada nivel reduce el costo de búsqueda en un lugar distinto. Para el grafo de conocimiento incluyo el benchmark de su creador; para los otros tres niveles, mediciones hechas en el propio repositorio de este blog.</p>
<p>Desde que vi <code>codegraph</code> en GitHub Trending y lo instalé yo mismo, cada vez que encuentro una herramienta nueva me pregunto con qué principio ahorra tokens. Muchas de las herramientas de este artículo también las conocí por primera vez en GitHub Trending, que suelo revisar por semana, filtrando por TypeScript y Python.</p>
<h2 id="herramientas-de-inteligencia-de-código"><a class="anchor" href="#herramientas-de-inteligencia-de-código">Herramientas de inteligencia de código</a></h2>
<p>Antes de cambiar código, un agente primero busca dónde está el código relevante. Es un bucle: busca con grep, lee archivos, filtra y vuelve a hacer grep. En el benchmark de CodeGraph que veremos más adelante, el lado que respondía sin la herramienta usó hasta 43 tool calls para una sola pregunta. Las herramientas de inteligencia de código son intentos de reducir este costo de búsqueda.</p>
<p>Pero "costo" no designa una sola cosa. Los tokens que procesó el modelo, el número de tool calls y los tokens que siguen en la context window cuando termina el trabajo se mueven por separado. En cada nivel, este artículo mira cuál de los tres reduce.</p>
<p>Divido estos intentos en los cuatro niveles (tiers) de abajo, según lo profundamente que cada herramienta entiende el código. No hay una clasificación establecida en la industria; la agrupación es mía. Todos los valores medidos en este artículo se obtuvieron el 2026-10-08 en el commit <code>36e5cfa</code> de este repositorio, y los tokens se contaron con <code>o200k_base</code> de tiktoken. Los valores difieren de los del tokenizador de Claude, así que sirven para comparar, no como cifras absolutas.</p>
<h3 id="empaquetado-de-contexto"><a class="anchor" href="#empaquetado-de-contexto">Empaquetado de contexto</a></h3>
<p>La solución más simple parte de la idea: "<strong>meterlo todo en una sola context window</strong>". No construye grafos ni hace indexación. Simplemente serializa todo el repositorio en un bloque de texto y se lo entrega entero al modelo.</p>
<p>La herramienta representativa es <strong>Repomix</strong>. Su formato de salida por defecto es XML, y su README enlaza la documentación de Anthropic sobre etiquetas XML. Tiene CLI, versión web, extensión de navegador y servidor MCP.</p>
<p>La ventaja de <strong>GitIngest</strong> es que no requiere instalación. Si cambias <code>github.com</code> por <code>gitingest.com</code> en una URL de GitHub, todo el repositorio se convierte en una sola página de texto. <strong>code2prompt</strong> (creado por Mufeed VH), una CLI en Rust, permite cambiar el formato de salida con plantillas.</p>
<p><strong>rtk</strong> (<code>rtk-ai/rtk</code>) va en una dirección algo distinta. Mientras las herramientas anteriores empaquetan todo el repositorio de una vez, rtk comprime la salida de los comandos de CLI que ejecuta el agente. Es un único binario hecho en Rust que se registra en los hooks de varios agentes, como Claude Code, Cursor, Copilot, Gemini CLI y Codex, de modo que cuando el agente llama a <code>git status</code>, ejecuta <code>rtk git status</code> en su lugar. Aplica filtering, grouping, truncation y deduplication a más de 100 comandos. El hook de rtk solo toma como objetivo las tool calls de Bash. <code>Read</code>, <code>Grep</code> y <code>Glob</code> de Claude Code pasan de largo.</p>
<p>La magnitud de la reducción hay que leerla con cuidado. El <a href="https://github.com/rtk-ai/rtk/blob/8533612180c60efbcb5827c7db4e910aba096705/README.md#L66-L70" target="_blank" rel="noopener noreferrer">README de rtk</a> dice que reduce la salida de Bash hasta en un 90%, y añade enseguida que eso no significa reducir la factura un 90%. Desde el lado del modelo, la salida de un comando es parte de los tokens de entrada, y los tokens de entrada son parte de la factura, así que la reducción se diluye en cada paso. La cifra que destaca el <a href="https://www.rtk-ai.app/" target="_blank" rel="noopener noreferrer">sitio oficial</a> es un 56% de media, en los comandos que rtk reescribe. Si las herramientas anteriores reducen el texto que entra, rtk reduce el texto que vuelve como resultado de las tool calls.</p>
<p>El límite de este nivel es que <strong>los repositorios grandes chocan con el tope de tokens</strong>. Empaquetar solo los 109 archivos del <code>src/</code> de este blog ya da 94.596 tokens. Eso todavía cabe en la ventana, pero un repositorio con miles de archivos llegaría al límite enseguida. Repomix responde a esto con <code>--compress</code>. Según el <a href="https://github.com/yamadashy/repomix/blob/8d6429121e98ed178e4d3a975c2bdbbecc958c4a/README.md#L797-L831" target="_blank" rel="noopener noreferrer">README</a>, usa Tree-sitter para conservar las firmas de funciones y clases y descartar los cuerpos de implementación.</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>Se redujo un 68%. Los comentarios se quedan, así que <code>visit-counter.ts</code>, con JSDoc largo, solo pasó de 1.642 a unos 1.220 tokens, un 26%. La versión comprimida conserva las sentencias import y las firmas, pero desaparecen las llamadas dentro de los cuerpos de las funciones. En el texto se ve qué archivo importa qué, pero no se puede preguntar quién llama a quién. Por eso la frontera entre este nivel y el siguiente está menos en si la herramienta ve la sintaxis que en si se le pueden preguntar relaciones.</p>
<h3 id="mapa-del-repositorio-con-tree-sitter"><a class="anchor" href="#mapa-del-repositorio-con-tree-sitter">Mapa del repositorio con tree-sitter</a></h3>
<p>El siguiente nivel usa <strong>tree-sitter</strong> para analizar la estructura del código, pero sin levantar un servidor de índices aparte.</p>
<p>Lo que construye tree-sitter, la herramienta en la que se apoya este nivel, es un <strong>CST (Concrete Syntax Tree)</strong>, un árbol que representa la estructura del código fuente. La <a href="https://tree-sitter.github.io/tree-sitter/" target="_blank" rel="noopener noreferrer">documentación oficial de tree-sitter</a> también dice que construye un concrete syntax tree. Es un árbol que conserva como nodos incluso los paréntesis y la puntuación, y es el árbol con el que trabajan las herramientas de abajo.</p>
<p><strong>tree-sitter</strong> es un generador de parsers de código abierto y una biblioteca de parsing incremental (incremental). La <a href="https://docs.github.com/en/repositories/working-with-files/using-files/navigating-code-on-github" target="_blank" rel="noopener noreferrer">code navigation de GitHub</a> usa tree-sitter. Como solo vuelve a parsear la parte editada, cambiar una línea en el editor no vuelve a parsear todo el archivo; solo corrige la parte del árbol que cambió. Esa ventaja es de los editores, donde las ediciones no paran. Aider, más abajo, mantiene una caché basada en la hora de modificación de los archivos para no volver a parsear los que no cambiaron.</p>
<p><strong>Aider</strong>, una herramienta de programación en pareja con IA que se usa en la terminal, es el ejemplo representativo de este enfoque. Aider usa tree-sitter para extraer de cada archivo las definiciones y referencias de funciones, clases y métodos. Luego construye un grafo con los archivos como nodos. Cuando el archivo A referencia un identificador definido en el archivo B, se crea una arista de A a B.</p>
<p>Para elegir los archivos importantes de este grafo, Aider usa PageRank. PageRank es un algoritmo que da más puntuación a un nodo cuantos más enlaces recibe y cuanto más pesan. Aider usa una variante llamada personalized PageRank, que inclina la puntuación hacia los nodos elegidos. Después mete en el presupuesto de tokens las definiciones y firmas de los archivos mejor clasificados.</p>
<p>Qué entra en el presupuesto cambia con la conversación actual. En <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> de Aider, el peso de una arista es la raíz cuadrada del número de referencias multiplicada por un factor. Los identificadores mencionados en la conversación reciben x10, y los identificadores en camelCase, snake_case o kebab-case de 8 o más caracteres también x10. Los que empiezan por <code>_</code> y los definidos en más de 5 archivos reciben x0,1 cada uno, y las aristas que salen de los archivos añadidos ahora al chat reciben x50. Los archivos del chat, los mencionados en la conversación y aquellos cuya ruta o nombre de archivo coincide con un identificador de la conversación también reciben puntuación de personalization en PageRank.</p>
<p>El presupuesto tampoco es fijo. La <a href="https://aider.chat/docs/repomap.html" target="_blank" rel="noopener noreferrer">documentación de Aider</a> da 1k como valor por defecto de <code>--map-tokens</code>, pero el <a href="https://github.com/Aider-AI/aider/blob/5dc9490bb35f9729ef2c95d00a19ccd30c26339c/aider/models.py#L782-L789" target="_blank" rel="noopener noreferrer">código</a> recorta un octavo del límite de entrada del modelo a un valor entre 1.024 y 4.096. Y cuando no hay archivos en el chat, lo amplía hasta <code>--map-multiplier-no-files</code> (por defecto 2) veces. Lo comprobé con el <code>src/</code> de este repositorio. La versión 0.86.1 que ejecuté y el commit enlazado arriba tienen el mismo código de ranking. Los maps impresos se contaron con o200k_base, incluidas las tres líneas de texto de guía que añade Aider.</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>Condición</th>
<th>Presupuesto elegido por Aider</th>
<th>Map impreso</th>
<th>Archivos incluidos</th>
</tr>
</thead>
<tbody>
<tr>
<td>Valores por defecto, sin archivos en el chat</td>
<td>4.096</td>
<td>7.370 tokens</td>
<td>61</td>
</tr>
<tr>
<td><code>--map-tokens 1024</code>, sin archivos en el 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> añadido al chat</td>
<td>1.024</td>
<td>983 tokens</td>
<td>12</td>
</tr>
</tbody>
</table>
<p>El límite de entrada de gpt-4o es de 128k, así que un octavo se recortó a 4.096, y con el chat vacío el map salió dentro del doble de eso. Al añadir un archivo al chat, el map se encogió hasta caber en el presupuesto; de los 27 archivos anteriores solo quedaron 10, y entraron como nuevos <code>PostList.tsx</code> y <code>SearchModal.tsx</code>. Un repo map no es un resumen fijo del repositorio, sino un extracto ajustado a la conversación de ese momento.</p>
<p>Hay una herramienta más que vale la pena mencionar en este nivel: <strong>ast-grep</strong> (<code>ast-grep/ast-grep</code>). Es una CLI de búsqueda estructural y rewrite basada en tree-sitter, y hace coincidir nodos del árbol sintáctico en lugar de texto. Por ejemplo, el patrón <code>console.log($A)</code> atrapa todas las llamadas a <code>console.log</code> con un solo argumento, sin importar cómo sean los saltos de línea o los espacios. Lo que atrapa es la misma estructura sintáctica. No sabe el tipo de <code>$A</code> ni de dónde viene el nombre <code>console</code>. También existe un servidor <code>ast-grep-mcp</code>, con el que se puede hacer que un agente use búsqueda estructural en lugar de grep de texto.</p>
<h3 id="knowledge-graph"><a class="anchor" href="#knowledge-graph">Knowledge Graph</a></h3>
<p>El tercer nivel va un paso más allá. <strong>Parsea de antemano toda la base de código, construye un grafo de conocimiento y lo guarda en disco</strong>; luego el agente lanza consultas a ese grafo guardado. El ejemplo del que más se habla es una herramienta llamada <strong>CodeGraph</strong>.</p>
<p>La estructura que describe el <a href="https://github.com/colbymchenry/codegraph/blob/b635dd467f0578926a9c01a37b9d28d2b26689f1/README.md" target="_blank" rel="noopener noreferrer">README de CodeGraph</a> es sencilla. Parsea el código con tree-sitter para extraer símbolos, aristas e información de archivos, y los guarda en una base de datos SQLite local. La búsqueda por nombre pasa por el índice FTS5 de SQLite. El agente consulta este grafo a través de MCP. En los pasos que describe el README no aparece ningún LLM. Por eso entiendo que esta extracción es determinista (deterministic), es decir, que el mismo código siempre da el mismo resultado.</p>
<p>Si un LLM resume el código para construir el grafo, los resultados pueden variar incluso con el mismo código, y pueden colarse alucinaciones. En cambio, si se parsea directamente el árbol sintáctico, las relaciones entre símbolos se extraen solo según las reglas de la gramática del lenguaje, sin margen para ese tipo de interpretación.</p>
<p><strong>FTS5 (SQLite Full-Text Search 5)</strong>, que aparece aquí, es una extensión de búsqueda de texto completo que SQLite ofrece en forma de tabla virtual. Según la <a href="https://www.sqlite.org/fts5.html" target="_blank" rel="noopener noreferrer">documentación de SQLite</a>, está incluida en la amalgamation desde la 3.9.0 (2015-10-14); se crea una tabla con <code>CREATE VIRTUAL TABLE ... USING fts5(...)</code> y se consulta con el operador <code>MATCH</code>. Permite tener un índice de texto completo en un solo archivo SQLite sin levantar un motor de búsqueda aparte como Elasticsearch.</p>
<p>Pero ser determinista no es lo mismo que ser completo. El mismo README registra el reconocimiento de rutas de los frameworks que dependen de convenciones y reflexión entre un 74,1% para Django y un 83,9% para ASP.NET, y lo llama el límite del análisis estático (honest static-analysis ceiling). El mismo código da el mismo resultado, pero a ese resultado le pueden faltar aristas.</p>
<p>El benchmark lo midió CodeGraph mismo. La nueva medición del 2026-08-05 en el mismo README ejecutó Claude Opus 4.8 en modo headless y planteó una pregunta de arquitectura a cada uno de 7 repositorios de código abierto. El lado con CodeGraph MCP activado redujo el costo medio un 44%, los tokens procesados un 62% y las tool calls un 88%. Esta nueva medición impidió en ambos lados llamar a la CLI <code>codegraph</code> desde Bash. En un entorno de medición sin ese bloqueo, el lado sin la herramienta encontró y usó la CLI en 26 de 28 ejecuciones, y el README aclara que las cifras publicadas antes se obtuvieron sin este bloqueo.</p>
<p>El tamaño del ahorro no siguió al tamaño del repositorio. En las preguntas en las que el lado sin la herramienta usó de 28 a 43 tool calls, el costo bajó entre un 57 y un 78%, y en Gin, que terminó en 7, quedó casi igual. VS Code, con unos 11k archivos, dio un 71%, y Excalidraw, con unos 640, un 78%. El README lo atribuye a la cantidad de búsqueda que exigía cada pregunta. Como hay una sola pregunta por repositorio, solo leo de ahí la dirección.</p>
<p>El README también registra una cifra en sentido contrario. Los tokens procesados bajan, pero al final de una sesión de varios turnos, los resultados de retrieval que siguen en la context window son alrededor de un 80% mayores del lado de CodeGraph en el conjunto de los 7 repositorios. La diferencia varía según el repositorio; solo en VS Code son 67k frente a 18k tokens, unas 3,7 veces. Devuelve el texto fuente denso de una vez, y ese texto se queda en la ventana. CodeGraph redujo las tool calls y los tokens procesados a cambio de dejar más en la ventana.</p>
<p><strong>Cursor</strong> tomó otro camino y luego cambió de rumbo. La indexación que describía la <a href="https://cursor.com/blog/secure-codebase-indexing" target="_blank" rel="noopener noreferrer">entrada del blog de Cursor de enero de 2026</a> no era un grafo sintáctico sino <strong>búsqueda semántica basada en embeddings vectoriales</strong>. Dividía los archivos en chunks localmente, se sincronizaba con el servidor mediante hashes de Merkle tree y convertía los chunks en embeddings para la búsqueda semántica. En julio de 2026, un Community Support Engineer de Cursor respondió en el <a href="https://forum.cursor.com/t/what-do-you-think-about-cursor-removing-the-codebase-indexing-settings/165899" target="_blank" rel="noopener noreferrer">foro</a>: "Semantic/embeddings indexing is being turned down in favor of grep-based retrieval". En el mismo hilo, otro miembro del equipo escribió que, a medida que los modelos aprendieron a usar bien grep, la antigua ruta de búsqueda semántica dejó de ayudar de forma significativa. Hoy la <a href="https://cursor.com/docs/context/codebase-indexing" target="_blank" rel="noopener noreferrer">documentación de Cursor</a> dice que Instant Grep construye y consulta su índice en tu máquina y no guarda embeddings de tu base de código para la búsqueda.</p>
<h3 id="lsp"><a class="anchor" href="#lsp">LSP</a></h3>
<p>El último nivel <strong>depende directamente de un servidor de lenguaje</strong>. Si tree-sitter sabe "que un símbolo existe", LSP sabe "qué es ese símbolo".</p>
<p><strong><a href="https://microsoft.github.io/language-server-protocol/" target="_blank" rel="noopener noreferrer">LSP (Language Server Protocol)</a></strong> es un protocolo abierto basado en JSON-RPC que estandariza la comunicación entre editores y herramientas de análisis de lenguaje (autocompletado, ir a la definición, buscar referencias, refactorización, etc.). En 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 y Codenvy anunciaron su colaboración</a>. La idea central es "no reimplementar un analizador de lenguaje para cada editor; tener un servidor por lenguaje y que todos los editores le pregunten". rust-analyzer y pyright de Python son servidores LSP, mientras que TypeScript usa typescript-language-server, que envuelve en LSP a <code>tsserver</code>, que habla su propio protocolo.</p>
<p><strong>Serena</strong> (<code>oraios/serena</code>) es un servidor MCP de este nivel. A 2026-10-08 tiene 30.093 stars, y el repositorio se creó en marzo de 2025. La idea central de Serena cabe en una línea: <strong>mostrarle al agente el código como símbolos.</strong> Sus herramientas principales incluyen <code>find_symbol</code>, <code>find_referencing_symbols</code> y <code>get_symbols_overview</code>. Se puede elegir uno de dos backends. El predeterminado es un servidor de lenguaje que implementa LSP (gratuito/de código abierto); la otra opción es un plugin de pago que usa el análisis de código del IDE de JetBrains (con prueba gratuita).</p>
<p>Medir en este repositorio muestra de dónde sale la diferencia. Busqué los usos de <code>isHiddenPost</code> (<code>src/lib/filter-posts.ts:12</code>), que filtra las entradas privadas, de dos formas. El lado de texto es 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>En el lado LSP se llamó a <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> de Serena. La búsqueda de referencias de LSP pregunta por posición (archivo, línea y columna), no por nombre, así que esta herramienta también recibe a la vez <code>name_path</code>, el nombre del símbolo, y <code>relative_path</code>, el archivo donde está la definición. En lugar de levantar Serena como servidor MCP, la herramienta se llamó directamente desde Python sobre el mismo 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>La salida es una sola línea de JSON. Este es solo el primer elemento. Los números de línea empiezan en 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 devolvió 10 referencias. Deja fuera la definición en <code>filter-posts.ts:12</code>, y para las líneas de import de tres archivos devuelve el propio archivo como símbolo (<code>File</code>). Las 11 ubicaciones de código que encontró grep son estas 10 más la línea de la definición. Este nombre es único en el repositorio, así que grep no omitió ni añadió ninguna ubicación de código. Las 5 ubicaciones de más que encontró grep eran frases de documentos que explican esta función: el <code>CLAUDE.md</code> de la raíz, los archivos de <code>.claude/commands/</code> <code>audit.md</code> y <code>write-post.md</code>, y, en <code>docs/research/snapshot-20260816/</code>, las copias que quedan de <code>CLAUDE.md</code> y <code>write-post.md</code>.</p>
<p>Serena devuelve cada referencia con <a href="https://github.com/oraios/serena/blob/3b99f8b024dafd58c962ea6e74f37c8a730ef532/src/serena/repl/api/lsp_api.py#L367-L369" target="_blank" rel="noopener noreferrer">una línea antes y otra después</a>. Por eso grep se ejecutó de dos formas, <code>git grep -n</code>, que solo devuelve números de línea, y <code>git grep -n -C1</code>, que también devuelve una línea antes y otra después, y las tres salidas se contaron completas con <code>o200k_base</code>.</p>
<table>
<thead>
<tr>
<th>Salida</th>
<th>Ubicaciones</th>
<th>tokens</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>git grep -n</code></td>
<td>16 (código 11, documentos 5)</td>
<td>703</td>
</tr>
<tr>
<td><code>git grep -n -C1</code></td>
<td>16 (código 11, documentos 5)</td>
<td>1.575 (código 733, documentos 841)</td>
</tr>
<tr>
<td>Serena <code>find_referencing_symbols</code></td>
<td>10</td>
<td>892</td>
</tr>
</tbody>
</table>
<p>Frente a un grep que devuelve una línea antes y otra después, Serena usa 683 tokens menos. La diferencia sale de no devolver las 5 ubicaciones en documentos. Pero Serena añade a cada referencia el nombre del símbolo y la información de ubicación en JSON, así que, mirando solo el lado del código, usa de hecho 159 tokens más. Y los 892 tokens de Serena superan los 703 de <code>git grep -n</code>, que solo devuelve números de línea. Lo que ahorra el nivel LSP en este repositorio no es el resultado de la búsqueda, sino el paso siguiente: leer líneas o archivos cercanos tras ver el resultado de grep.</p>
<p>En ese paso siguiente, si el agente leyera enteros los 9 archivos que encontró grep, leería 47.034 tokens: 3.716 de los 4 archivos de código y 43.318 de los 5 documentos. Eso es una cota superior. En una sesión real, <code>CLAUDE.md</code> probablemente ya esté en el context y no se vuelva a leer. Con un nombre común como <code>isLocale</code>, los resultados podrían diferir incluso en las ubicaciones de código.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En resumen, los cuatro niveles se separan según lo profundamente que entienden el código, y reducen costos distintos. Context packing entrega el código como texto, e incluso con <code>--compress</code> conserva solo la sintaxis y reduce los tokens que entran. El repo map de tree-sitter sabe que un símbolo existe, pone un tope de presupuesto a los tokens que entran, y la conversación de ese momento decide con qué se llena. El grafo de conocimiento guarda las relaciones de antemano y reduce las tool calls y los tokens procesados, pero según la medición del propio proveedor, lo que queda en la ventana incluso aumentó. LSP sabe qué es un símbolo; en este repositorio no devolvió el mismo nombre en los documentos, pero a cambio añadió metadatos a cada resultado. Lo que ahorra no está en el resultado de la búsqueda, sino en la lectura que viene después.</p>
<p>Por eso, al elegir una herramienta, primero miro qué costo es el problema ahora, más que la profundidad del nivel. Si la ventana es pequeña y las sesiones largas, miro lo que queda; si los viajes de ida y vuelta son lentos, el número de tool calls; si en el repositorio los documentos y el código comparten nombres, cuántos archivos acabo leyendo tras un resultado de grep. Leo que Cursor quitara la búsqueda semántica por embeddings y pasara a un índice de texto local (Instant Grep) como una señal de que un índice caro no siempre supera la propia capacidad de búsqueda del modelo.</p>
<p>Si estas herramientas reducen el costo de que un agente encuentre código, cuántas reglas del proyecto que el agente debe conocer desde el principio escribir, y en qué archivo, es otro problema. Lo trato en <a href="/260529" target="_blank" rel="noopener noreferrer">Archivos de contexto</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>IA</category>
            <category>Herramientas-de-desarrollo</category>
            <category>Claude</category>
            <category>MCP</category>
            <category>CodeGraph</category>
        </item>
        <item>
            <title><![CDATA[MCP y function calling]]></title>
            <link>https://hooninedev.com/es/260524</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260524</guid>
            <pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta publicación quiero hablar sobre en qué se diferencia MCP (Model Context Protocol) de function calling. Este artículo está pensado para quienes usan servidores MCP con Claude Code o Cursor, per...]]></description>
            <content:encoded><![CDATA[<p>En esta publicación quiero hablar sobre <strong>en qué se diferencia MCP (Model Context Protocol) de function calling</strong>.</p>
<p>Este artículo está pensado para quienes usan servidores MCP con Claude Code o Cursor, pero les cuesta explicar en qué punto se separa MCP del function calling de las API de LLM. La respuesta corta es que MCP no sustituye a function calling. La aplicación host convierte la lista de herramientas que recibe de un servidor MCP en el parámetro <code>tools</code> de function calling y devuelve al servidor MCP la llamada que elige el modelo. Al terminar, sabrás de qué parte del protocolo surge cada una de las cuatro diferencias que MCP añade encima, qué diferencias sobreviven a la revisión 2026-07-28 y qué superficie de ataque abre esa estructura.</p>
<p>Trabajo como desarrollador frontend y uso Claude a diario, pero cada vez que añadía un servidor MCP, nunca tenía del todo claro cómo entraban esas herramientas en el campo de visión del modelo.</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) resuelve la cuestión de «<strong>qué debe poder hacer el agente</strong>».</p>
<p>Veámoslo con más detalle. Para que un agente de IA envíe un mensaje a Slack, debe poder llamar a la Slack API. Para crear una issue de GitHub, debe poder llamar a la GitHub API. Para consultar Postgres, debe gestionar una conexión con la base de datos. MCP <strong>agrupa todas estas integraciones con sistemas externos bajo un protocolo estándar</strong>. (Significa que clientes y servidores se conectan siguiendo la misma especificación).</p>
<p>MCP es un estándar abierto que Anthropic presentó por primera vez el <strong>25 de noviembre de 2024</strong>. El <strong>9 de diciembre de 2025</strong>, Anthropic donó MCP a la <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> de la Linux Foundation. La AAIF la cofundaron Anthropic, Block y OpenAI.</p>
<p>MCP es un protocolo construido sobre JSON-RPC. <a href="https://www.jsonrpc.org/specification" target="_blank" rel="noopener noreferrer">JSON-RPC 2.0</a> es un protocolo RPC (Remote Procedure Call) stateless y ligero que utiliza JSON como wire format. Es independiente de la capa de transporte, por lo que puede funcionar sobre HTTP, TCP o la entrada y salida estándar. Este artículo toma como referencia la revisión 2025-11-25, en la que MCP es un protocolo stateful que establece una sesión por cada conexión. Qué cambió en la revisión posterior lo veremos después de recorrer el flujo de llamadas.</p>
<h3 id="seis-primitivas"><a class="anchor" href="#seis-primitivas">Seis primitivas</a></h3>
<p>La visión general de la especificación 2025-11-25 enumera tres funciones que ofrecen los servidores y tres que ofrecen los clientes. Este artículo llama primitivas a estas seis. Aquí, una primitiva no tiene nada que ver con los tipos primitivos de JavaScript (como string o number); se refiere a un tipo básico de interacción definido por el protocolo.</p>
<h4 id="primitivas-del-lado-del-servidor"><a class="anchor" href="#primitivas-del-lado-del-servidor">Primitivas del lado del servidor</a></h4>
<ul>
<li><strong>Tool</strong> (model-controlled): una acción cuya invocación decide el propio modelo. Estas acciones pueden tener efectos secundarios (side effects)</li>
<li><strong>Resource</strong> (application-controlled): datos identificados por una URI. La especificación solo tiene <code>resources/read</code> para leer su contenido y ningún método para escribir. Cómo se incorporan esos recursos al contexto lo decide la aplicación host</li>
<li><strong>Prompt</strong> (user-controlled): una plantilla reutilizable que el usuario activa explícitamente, por ejemplo con un comando de barra</li>
</ul>
<h4 id="primitivas-del-lado-del-cliente"><a class="anchor" href="#primitivas-del-lado-del-cliente">Primitivas del lado del cliente</a></h4>
<ul>
<li><strong>Sampling</strong>: un mecanismo que permite al servidor pedir en sentido inverso una completion al LLM del cliente, lo que vuelve bidireccional la relación entre cliente y servidor. Sirve para que un servidor que necesita generar texto mientras ejecuta una herramienta use el modelo del cliente sin tener su propia clave de API. En la revisión 2026-07-28 quedó deprecated, es decir, con su eliminación prevista</li>
<li><strong>Roots</strong>: información sobre los límites del espacio de trabajo con la que el cliente le dice al servidor «hasta aquí llega el área en la que puedes trabajar»</li>
<li><strong>Elicitation</strong>: una función que permite al servidor pedir al usuario datos adicionales de forma estructurada mientras ejecuta una herramienta</li>
</ul>
<p>Esta distinción importa porque <strong>quién decide invocar o proporcionar algo es distinto</strong>. Un Tool se ejecuta por decisión del modelo, así que una invocación errónea implica riesgo, mientras que un Prompt lo elige el usuario de forma explícita. Un Resource lo elige la aplicación por defecto, pero la especificación también permite implementaciones que incluyen recursos automáticamente, según heurísticas o la selección del modelo. Las tres primitivas del lado del cliente van en sentido contrario: el servidor pide y el cliente decide si responde.</p>
<h3 id="dos-mecanismos-de-transporte"><a class="anchor" href="#dos-mecanismos-de-transporte">Dos mecanismos de transporte</a></h3>
<p>Hay dos <a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" target="_blank" rel="noopener noreferrer">mecanismos de transporte estándar</a>, y la especificación también permite otros transportes personalizados (MAY). El primero es <strong>stdio</strong>, que ejecuta el servidor MCP como un subproceso local y se comunica mediante la entrada y salida estándar. Es adecuado para herramientas que funcionan localmente, como el sistema de archivos o git. El segundo es <strong>Streamable HTTP</strong>, que añade streaming SSE (Server-Sent Events) sobre HTTP POST y GET para lograr una comunicación casi bidireccional. SSE es una forma de que el servidor envíe datos al cliente en una sola dirección a través de una conexión HTTP. Es adecuado para escenarios que ocurren a través de la red, como servidores remotos, autenticación OAuth, conexiones de varios clientes y despliegues en la nube.</p>
<h3 id="flujo-mediante-el-que-un-llm-invoca-herramientas-mcp"><a class="anchor" href="#flujo-mediante-el-que-un-llm-invoca-herramientas-mcp">Flujo mediante el que un LLM invoca herramientas MCP</a></h3>
<p>Ya que hemos visto las primitivas y los transportes, sigamos el flujo de <strong>cómo un LLM descubre e invoca realmente una herramienta MCP</strong>.</p>
<p>En la revisión 2025-11-25, al iniciarse una conexión se produce el siguiente handshake. A fecha de 2026-10-08, el SDK de TypeScript 1.32.1 y la versión 2.3.1 de la línea v2 también siguen este orden con su configuración por defecto.</p>
<ul>
<li><strong>Cliente → servidor</strong>: solicitud <code>initialize</code> (envía la versión de protocolo admitida y las capabilities del cliente)</li>
<li><strong>Servidor → cliente</strong>: respuesta <code>initialize</code> (capabilities del servidor y, opcionalmente, el campo <code>instructions</code>)</li>
<li><strong>Cliente → servidor</strong>: notificación <code>notifications/initialized</code></li>
<li><strong>Cliente → servidor</strong>: solicitud <code>tools/list</code> → recibe la lista de herramientas disponibles</li>
<li>(Después) El LLM decide invocar una herramienta → el cliente envía <code>tools/call</code> → recibe el resultado</li>
</ul>
<p>En la respuesta <code>initialize</code>, el campo <code>instructions</code> es donde el servidor envía un texto sobre cómo deben usarse sus herramientas. La línea instructions de la salida de la demo, más abajo, muestra ese valor.</p>
<p>Entonces, ¿cómo entra la propia definición de la herramienta en el campo de visión del LLM? La definición de una herramienta MCP tiene esta forma de JSON Schema.</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>El host convierte la lista recibida con <code>tools/list</code> en el <strong>parámetro <code>tools</code> de la Anthropic Messages API</strong> o en el <strong>parámetro <code>tools</code> de OpenAI function calling</strong>, y la incluye en la llamada a la API del LLM. En el caso de Anthropic, cuando llega el parámetro de herramientas, se <a href="https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview" target="_blank" rel="noopener noreferrer">añade automáticamente un special system prompt</a> para que el modelo entienda cómo invocarlas. Su longitud depende del modelo. En la tabla de la documentación que consulté el 2026-10-08, los modelos actuales añadían entre 286 y 675 tokens con <code>tool_choice: auto</code>.</p>
<p>Cuando el LLM decide que debe invocar una herramienta, su respuesta incluye un bloque <code>tool_use</code> (<code>{"type": "tool_use", "name": ..., "input": ...}</code>) y el <code>stop_reason</code> de la respuesta termina en <code>tool_use</code>. El host lo recibe, envía <code>tools/call</code> al servidor MCP real, recibe el resultado, lo coloca en un bloque <code>tool_result</code> del siguiente mensaje user y lo envía de nuevo al LLM. <strong>Este bucle se repite hasta que <code>stop_reason</code> cambia de <code>tool_use</code> a otro valor, como <code>end_turn</code> o <code>max_tokens</code>.</strong> Lo que solemos llamar «el agente trabajando» se parece mucho a una sucesión de estos bucles de llamada, resultado y nueva llamada.</p>
<p>Si lo dibujamos, no hay ninguna línea entre el modelo y el servidor MCP. El modelo solo ve las definiciones que recibió mediante <code>tools</code>, y el servidor MCP recibe las solicitudes del host, no del modelo. Lo que une los dos protocolos es el host del centro.</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="Entre la API del LLM a la izquierda y el host en el centro van flechas etiquetadas tools, tool_use y tool_result, y entre el host y el servidor MCP a la derecha van flechas etiquetadas tools/list y tools/call. No hay ninguna línea entre la API del LLM y el servidor MCP" style="max-width:min(720px, 100%);height:auto;" width="2000" height="640" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>Ejecuté esta conversión para ver lo corta que es en realidad. Se ejecutó el 2026-10-08 con Node v24.16.0, <code>@modelcontextprotocol/sdk</code> 1.32.1 y <code>zod</code> 4.6.5. En lugar de stdio, el transporte es <code>InMemoryTransport</code>, que conecta servidor y cliente dentro de un mismo proceso, y no se llamó a ningún LLM. El bloque <code>tool_use</code> se construyó a mano con la forma que muestra la documentación de Anthropic.</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>La salida de <code>node post-demo.mjs</code> es esta.</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>La conversión de la definición de la herramienta no es más que renombrar campos. El <code>inputSchema</code> de MCP pasa a ser <code>input_schema</code> en Anthropic y <code>parameters</code> en OpenAI. La salida también muestra que el SDK añade <code>$schema</code> al esquema. La dirección contraria es igual de corta. El <code>tool_use.input</code> de Anthropic es un objeto, así que entra tal cual en <code>tools/call</code> como sus <code>arguments</code>. En el lado del resultado, los bloques de texto tienen la misma forma y pasan tal cual, pero los resultados de imagen o de error tienen otra forma, así que el helper de MCP del SDK de Anthropic los convierte por separado. La forma de OpenAI de arriba es el formato de la Responses API. Los <code>arguments</code> de una llamada que devuelve OpenAI son una <a href="https://developers.openai.com/api/docs/guides/function-calling" target="_blank" rel="noopener noreferrer">cadena JSON</a>, así que hay que pasarlos una vez por <code>JSON.parse</code> antes de entregarlos. El código de arriba solo construye un bloque <code>tool_use</code> con la forma de Anthropic, así que este paso de parse no aparece en la salida.</p>
<h3 id="cuatro-cosas-que-añade-mcp"><a class="anchor" href="#cuatro-cosas-que-añade-mcp">Cuatro cosas que añade MCP</a></h3>
<p>Entonces, ¿qué añade MCP a function calling? Según la revisión 2025-11-25, son cuatro cosas.</p>
<ul>
<li><strong>Descubrimiento dinámico</strong>: la lista de herramientas no se conoce en tiempo de compilación, sino que se obtiene en tiempo de ejecución con <code>tools/list</code>. El servidor puede avisar con <code>notifications/tools/list_changed</code> de que la lista cambió durante la conexión</li>
<li><strong>Stateful session</strong>: la conexión se establece con <code>initialize</code> y las solicitudes se intercambian dentro de ella. No hay un mensaje JSON-RPC de cierre específico; la sesión termina cerrando el transporte</li>
<li><strong>Primitivas además de Tool</strong>: Resource, Prompt, Sampling, Roots y Elicitation se exponen mediante capability negotiation. La capability negotiation es el paso de <code>initialize</code> en el que cada lado anuncia las funciones que admite</li>
<li><strong>Bidireccionalidad</strong>: el servidor puede pedir en sentido inverso una completion al LLM del cliente mediante Sampling (deprecated en la revisión 2026-07-28)</li>
</ul>
<h3 id="después-de-la-revisión-2026-07-28"><a class="anchor" href="#después-de-la-revisión-2026-07-28">Después de la revisión 2026-07-28</a></h3>
<p>A fecha de 2026-10-08, la revisión que el sitio oficial abre como latest es la <a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" target="_blank" rel="noopener noreferrer">revisión 2026-07-28</a>, y en ella cambió la mitad de esta lista. Primero, desaparecieron el handshake formado por <code>initialize</code> y <code>notifications/initialized</code> y las sesiones a nivel de protocolo. En su lugar, cada solicitud lleva en <code>_meta</code> la versión del protocolo y las capabilities del cliente. Ese campo es un espacio que MCP reserva para adjuntar metadatos aparte de los parámetros propios del mensaje. Los servidores MUST implementar <code>server/discover</code>. Es una RPC que devuelve las versiones del protocolo que admite el servidor, sus capabilities y su información, y el cliente puede llamarla antes de cualquier otra solicitud para comprobar de antemano las versiones y capabilities admitidas.</p>
<p>Las solicitudes que antes enviaba primero el servidor se sustituyeron por un patrón llamado Multi Round-Trip Requests (MRTR). En lugar de enviar una solicitud aparte, el servidor devuelve un resultado intermedio que indica que necesita más datos (<code>input_required</code>), y el cliente completa esos datos y vuelve a enviar la solicitud original.</p>
<p>Sampling y Roots quedaron deprecated junto con Logging. Siguen en la especificación y funcionan, pero las implementaciones nuevas no deberían adoptarlos, y la especificación recomienda integrarse directamente con las API de los proveedores de LLM en lugar de usar Sampling.</p>
<p>Aun así, el comportamiento por defecto de los SDK sigue siendo el antiguo. El SDK de TypeScript 1.32.1 tiene <code>2025-11-25</code> como constante de versión más reciente y no conoce la revisión 2026-07-28, y la 2.3.1 admite esta revisión, pero su negociación de versión por defecto es <code>legacy</code>. La primera línea de la salida de arriba, <code>initialize 2025-11-25</code>, es el resultado.</p>
<p>Lo que queda de las cuatro, entonces, es el descubrimiento dinámico y las primitivas además de Tool (Resource, Prompt, Elicitation). El descubrimiento dinámico también cambió un poco de forma: las notificaciones de cambio de lista solo llegan a los clientes que se suscriben (opt-in) al stream <code>subscriptions/listen</code>. Al final, el punto en que MCP se separa de function calling está menos en las sesiones o la bidireccionalidad y más en <strong>un contrato para intercambiar la lista de herramientas y el contexto en tiempo de ejecución</strong>.</p>
<h3 id="cuando-la-api-es-el-cliente-mcp"><a class="anchor" href="#cuando-la-api-es-el-cliente-mcp">Cuando la API es el cliente MCP</a></h3>
<p>El <a href="https://platform.claude.com/docs/en/agents-and-tools/mcp-connector" target="_blank" rel="noopener noreferrer">MCP connector</a> (beta) de Anthropic muestra cuánto de este contrato llega al modelo. Es una función con la que la Messages API se conecta directamente a un servidor MCP remoto, y la sección Limitations de su documentación dice que, de las funciones de la especificación MCP, "only tool calls are currently supported", y que "Local STDIO servers cannot be connected directly". La misma documentación indica que, si necesitas servidores locales, prompts o resources de MCP, gestiones tú la conexión con un SDK de MCP y uses los helpers de conversión del SDK de Anthropic.</p>
<p>Es decir, cuando MCP se consume en la capa de function calling, solo queda Tool. Resource y Prompt solo tienen sentido si hay un host que los lleve a la pantalla o al contexto. La guía de function calling de OpenAI también presenta una forma de usar la funcionalidad de un servidor MCP como built-in tool. La <a href="https://developers.openai.com/api/docs/guides/tools-connectors-mcp" target="_blank" rel="noopener noreferrer">guía MCP servers</a> de OpenAI solo explica cómo listar y llamar herramientas, y no dice si admite Resource o Prompt.</p>
<h3 id="la-superficie-de-ataque-del-descubrimiento-dinámico"><a class="anchor" href="#la-superficie-de-ataque-del-descubrimiento-dinámico">La superficie de ataque del descubrimiento dinámico</a></h3>
<p>Las descripciones de las herramientas y los resultados de sus invocaciones llegan, a través del host, al contexto del modelo. Así que, escriba lo que escriba ahí el servidor, el modelo lo lee. Los dos ataques representativos nacen de ahí.</p>
<ul>
<li>
<p><strong>Tool Poisoning Attack (TPA)</strong>: un ataque al que <a href="https://invariantlabs.ai/blog/mcp-security-notification-tool-poisoning-attacks" target="_blank" rel="noopener noreferrer">Invariant Labs</a> dio nombre y del que publicó una PoC en abril de 2025. Si se ocultan instrucciones maliciosas en la descripción (description) de una herramienta de un servidor MCP, el modelo lee ese texto, que el usuario no ve, y puede seguirlo sin que el usuario lo sepa.</p>
</li>
<li>
<p><strong>Rug Pull</strong>: un ataque, descrito por Invariant Labs en la misma publicación, en el que el servidor cambia la definición de una herramienta después de que el usuario la aprobó. Como en el ejemplo de Elena Cross <a href="https://simonwillison.net/2025/Apr/9/mcp-prompt-injection/" target="_blank" rel="noopener noreferrer">citado por Simon Willison</a>, apruebas el día 1 una herramienta de aspecto seguro y, para el día 7, esa herramienta ha cambiado para enviar tus claves de API a un atacante; ocurre porque las definiciones de herramientas se obtienen del servidor en tiempo de ejecución y no en el momento de la instalación. La <a href="https://modelcontextprotocol.io/specification/2025-11-25/server/tools" target="_blank" rel="noopener noreferrer">especificación de tools</a> recomienda (SHOULD) una UI que muestre qué herramientas se exponen al modelo, pero no exige volver a aprobar una definición modificada, así que esa nueva aprobación queda en manos del host.</p>
</li>
</ul>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En resumen, MCP no sustituye a function calling; es un estándar que se apoya sobre él. El modelo sigue invocando herramientas mediante el parámetro <code>tools</code> y el bucle de <code>tool_use</code>, y el host traduce entre los dos protocolos. En la revisión 2025-11-25, lo que añadía MCP era el descubrimiento dinámico, una stateful session, primitivas además de Tool y llamadas que van del servidor al cliente. Al desaparecer las sesiones y quedar Sampling deprecated en la revisión 2026-07-28, lo que queda es un contrato para intercambiar la lista de herramientas y el contexto en tiempo de ejecución. Por ese contrato, los ataques que envenenan las definiciones de herramientas o las cambian en silencio surgen en el mismo lugar. Cuando añadas otro servidor MCP, te recomiendo comprobar no solo qué puede hacer ese servidor, sino también si tu host te avisa cuando cambian sus definiciones.</p>
<p>Si MCP trata de qué permitir hacer al agente, qué contarle es tarea de archivos de contexto como <code>CLAUDE.md</code> o <code>AGENTS.md</code>. Cómo lee el agente esos archivos y hasta qué punto se respetan sus instrucciones se trata en <a href="/260529" target="_blank" rel="noopener noreferrer">Archivos de contexto</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>IA</category>
            <category>Herramientas-de-desarrollo</category>
            <category>Claude</category>
            <category>MCP</category>
        </item>
        <item>
            <title><![CDATA[Gestión del estado]]></title>
            <link>https://hooninedev.com/es/260518</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260518</guid>
            <pubDate>Mon, 18 May 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta publicación quiero hablar sobre la gestión del estado (State Management). No es una comparativa de librerías. Más que decidir qué herramienta es mejor, el objetivo es ordenar el criterio con e...]]></description>
            <content:encoded><![CDATA[<p>En esta publicación quiero hablar sobre la <strong>gestión del estado (State Management)</strong>. No es una comparativa de librerías. Más que decidir qué herramienta es mejor, el objetivo es ordenar el criterio con el que <strong>entendemos el estado</strong> y determinamos <strong>dónde trazar sus límites</strong>.</p>
<p>Hoy las herramientas de IA (Claude, ChatGPT, Cursor, Gemini, Copilot) ya forman parte integral de nuestro trabajo. La velocidad de desarrollo ha crecido exponencialmente, pero, para ser franco, tengo la impresión de que la calidad final de los servicios no ha avanzado al mismo ritmo. Cada vez es más habitual encontrarnos con tantos errores nuevos como funcionalidades añadidas, y también escuchar: «No sé por qué esto terminó así».</p>
<p>Cuando desarrollamos con tanta rapidez, dejamos de examinar el código línea por línea con el mismo detenimiento. Precisamente por eso, considero aún más necesario contar con <strong>los fundamentos necesarios para orientar a la IA en la dirección correcta</strong>. Para mantener la calidad del resultado, debemos ser capaces de detectar los problemas del código generado por la IA y volver a guiarla hacia el objetivo deseado. Entre esos fundamentos se encuentran el desarrollo desde la perspectiva del dominio, la abstracción, el TDD (Test-Driven Development, desarrollo guiado por pruebas), el uso adecuado de librerías y la optimización del rendimiento.</p>
<p>Sin embargo, cada vez que pregunto a colegas de frontend y de otras áreas de TI «¿cuál es la tarea más difícil del desarrollo frontend?», la respuesta más frecuente es siempre la misma: <strong>«Gestionar el flujo del estado»</strong>.</p>
<p>En este artículo trataré de explicar por qué gestionar el flujo del estado resulta tan difícil y qué criterio e intuición conviene desarrollar para hacerlo bien.</p>
<h2 id="qué-es-el-estado-state"><a class="anchor" href="#qué-es-el-estado-state">¿Qué es el estado (State)?</a></h2>
<p>Antes de entrar de lleno en el tema, empecemos por la pregunta más básica: ¿qué es exactamente eso que llamamos «estado»?</p>
<p>Mientras estudiaba desarrollo frontend, solía leer artículos de <a href="https://blog.hoseung.me/2021-12-05-state-management" target="_blank" rel="noopener noreferrer">hoseung.me</a>. Allí se define el estado como <strong>«todos los datos que pueden afectar a la UI»</strong>. El número de «me gusta», los productos del carrito, si un modal está abierto, los valores introducidos, la información del usuario autenticado, la pestaña seleccionada, los resultados de búsqueda o el estado de carga: todo eso es estado.</p>
<p>La documentación oficial de React ofrece una definición algo más formal. El propio título de la página es <a href="https://react.dev/learn/state-a-components-memory" target="_blank" rel="noopener noreferrer">"State: A Component's Memory"</a>, que podríamos desarrollar como <strong>«el mecanismo mediante el cual un componente retiene datos entre renderizados y hace que React dispare un nuevo renderizado cuando esos datos se actualizan»</strong>. Es decir, son datos que no desaparecen con el paso del tiempo, que se actualizan a raíz de algún evento y que, al hacerlo, provocan que la UI vuelva a dibujarse. Hay otro punto importante: el estado está <strong>aislado en cada instancia del componente</strong>. Aunque haya diez instancias del mismo componente en una página, cada una conserva su propio estado independiente. Este hecho se relaciona directamente con la cuestión que veremos más adelante: «¿dónde debe residir el estado?».</p>
<p>Ambas definiciones apuntan a lo mismo: el estado es <strong>«un valor que cambia con el tiempo y afecta al renderizado»</strong>. Una constante que no cambia no es estado. Un token de diseño primitivo fijado en build time no es estado, mientras que el modo oscuro que activa o desactiva el usuario sí lo es. (En rigor, el valor se resuelve según el estado del tema oscuro o claro; por tanto, es más preciso considerar que la «selección del tema» es el estado y que el token es el espejo en el que ese estado se refleja).</p>
<p>Conviene señalar algo más: <strong>no todo el estado vive en los componentes</strong>. Parte vive en cookies; otra, en localStorage, sessionStorage o IndexedDB; y otra, en la URL. Cuando traemos al cliente datos que residen en el servidor y los almacenamos en caché, también se convierten en una forma de estado. Incluso la posición de scroll o el stack del historial que mantiene el navegador deben tratarse a veces como estado, porque determinan el comportamiento de nuestra aplicación.</p>
<h2 id="por-qué-es-tan-difícil"><a class="anchor" href="#por-qué-es-tan-difícil">¿Por qué es tan difícil?</a></h2>
<p>Pensemos primero en términos sencillos por qué cuesta tanto manejar el estado. ¿No bastaría con crear el estado necesario, llevarlo hasta donde haga falta y gestionar bien sus actualizaciones y su reinicialización?</p>
<p>Con esta pregunta en mente, abramos una página del servicio en el que estamos trabajando.</p>
<p>¿Cuántos componentes contiene? Incluso una página sencilla puede tener desde decenas hasta cientos de componentes organizados en forma de árbol. Cada componente puede mantener su propio estado, compartirlo con componentes hermanos o recibirlo de su padre. El estado también se transfiere entre páginas; parte debe sobrevivir a una recarga y parte debe desaparecer al cerrar la pestaña.</p>
<p>La verdadera razón por la que el estado es difícil de gestionar es esta: <strong>no podemos ver de un vistazo dónde se declaran todos esos estados, cómo se actualizan ni cuándo desaparecen</strong>. Cuantos más componentes con funciones similares aparecen, más difícil resulta nombrar el estado y rastrear el código que lo modifica.</p>
<p>Así se forma una telaraña invisible. Un clic en el componente A invalida los datos de B; esa invalidación cierra la UI de C; y al cerrarse C desaparece el contenido de un formulario. Si esta cadena no está expresada explícitamente en ninguna parte del código, cuando depuramos un error tenemos que reconstruir la telaraña en nuestra cabeza.</p>
<p>Entonces, ¿cómo podemos ordenar esa telaraña? A mi juicio, el primer paso consiste en reconocer que <strong>«existen distintos tipos de estado»</strong>.</p>
<h2 id="no-todos-los-estados-son-iguales"><a class="anchor" href="#no-todos-los-estados-son-iguales">No todos los estados son iguales</a></h2>
<p><a href="https://kentcdodds.com/blog/application-state-management-with-react" target="_blank" rel="noopener noreferrer">Kent C. Dodds</a> divide el estado entre <strong>Server Cache</strong> (información que reside en el servidor y que el cliente conserva para acceder a ella rápidamente) y <strong>UI State</strong> (estado que solo existe en la UI para controlar el comportamiento de la interfaz). A menudo cometemos errores al agrupar ambos.</p>
<p>La <a href="https://tanstack.com/query/latest/docs/framework/react/guides/does-this-replace-client-state" target="_blank" rel="noopener noreferrer">documentación oficial de TanStack Query</a> lo define como una librería de server-state que gestiona operaciones asíncronas entre el servidor y el cliente, mientras que herramientas como Redux, MobX y Zustand son librerías de client-state. (Aunque pueden almacenar datos asíncronos, hacerlo resulta ineficiente).</p>
<p>La idea central es clara: <strong>Server State y Client State son problemas distintos</strong>. Server State es asíncrono, puede ser modificado por otros usuarios y, con el tiempo, pasa a estar stale. Client State es síncrono, está bajo nuestro control y desaparece al recargar la página. (Para ser exactos, cuando la página se descarga, <strong>el runtime de JavaScript se reinicia y tanto el árbol de componentes como el estado alojado en la memoria heap son liberados</strong>. Por eso, al montarse de nuevo, <code>useState</code> vuelve a comenzar desde su valor inicial). Si intentamos gestionar ambos con la misma herramienta, tendremos que implementar por nuestra cuenta patrones como la invalidación de caché, la actualización en background o las actualizaciones optimistas.</p>
<p>Doy un paso más y clasifico el estado del frontend en <strong>siete categorías</strong>. Conviene aclarar de antemano que estas siete categorías no se separan limpiamente sobre un único eje. Mezclan ubicación de almacenamiento, origen, ciclo de vida y función, por lo que un mismo estado puede pertenecer a varias categorías a la vez. No pretenden ser una taxonomía perfecta, sino <strong>preguntas que debemos plantearnos al decidir cómo gestionar el estado</strong>.</p>
<ul>
<li><strong>Estado local (Local State)</strong>: Estado usado solo dentro de un componente o de un subárbol reducido</li>
<li><strong>Estado global (Global State)</strong>: Estado que debe compartirse en toda la aplicación</li>
<li><strong>Estado del servidor (Server State)</strong>: Estado cuya fuente de verdad es el servidor y cuya copia en el cliente es una caché</li>
<li><strong>Estado del formulario (Form State)</strong>: Estado temporal que existe mientras el usuario introduce datos</li>
<li><strong>Estado de la URL (URL State)</strong>: Estado compartible que vive en la barra de direcciones y sobrevive a una recarga</li>
<li><strong>Estado externo (External State)</strong>: Estado fuera de React, como cookies, localStorage, sessionStorage e IndexedDB</li>
<li><strong>Guard de estado (State Guard)</strong>: Lógica que bloquea, permite o valida accesos y acciones según combinaciones de estado, en lugar de ser estado por sí misma</li>
</ul>
<p>Además, existen estados de flujo que conviene modelar con una máquina de estados y estados colaborativos en tiempo real basados en WebSocket o CRDT.</p>
<p>Veamos, una por una, por qué cada categoría requiere herramientas diferentes y con qué criterio debemos abordarla.</p>
<h2 id="estado-local-local-state"><a class="anchor" href="#estado-local-local-state">Estado local (Local State)</a></h2>
<p>Es el tipo de estado más sencillo. Solo se utiliza dentro de un componente y, desde fuera, no hay necesidad ni motivo para conocerlo. Algunos ejemplos son si un modal está abierto, el estado on/off de un botón toggle, el estado de hover o el término de búsqueda que se está escribiendo.</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>Hasta aquí, probablemente todo resulte familiar. Sin embargo, la verdadera dificultad del estado local reside en decidir <strong>«dónde debe ubicarse este estado»</strong>.</p>
<p>En su artículo sobre <a href="https://kentcdodds.com/blog/state-colocation-will-make-your-react-app-faster" target="_blank" rel="noopener noreferrer">State Colocation, Kent C. Dodds</a> señala que <strong>la gente está acostumbrada a «elevar» el estado (lift up), pero rara vez vuelve a «colocarlo cerca» (colocate) cuando el código cambia</strong>.</p>
<p>Elevar el estado es algo que hacemos de forma natural cuando varios componentes hermanos necesitan compartirlo. Como ambos deben ver los mismos datos, trasladamos el estado al padre común y lo pasamos hacia abajo mediante props.</p>
<p>El problema aparece cuando esos componentes hermanos dejan de necesitarlo. No solemos <strong>volver a bajar</strong> el estado hacia los hijos. Como resultado, el componente padre termina acumulando estados que en realidad no le conciernen y, cada vez que vuelve a renderizarse, todo el árbol de hijos se renderiza con él.</p>
<p>Por eso, el primer criterio para el estado local es: <strong>para ganar velocidad y simplicidad, coloca el estado tan cerca como sea posible del código que lo utiliza</strong>. Si un estado solo se usa en uno de los hijos de un componente, no hay razón para que lo mantenga el padre. Movámoslo al interior de ese hijo. El padre será más ligero.</p>
<h2 id="estado-global-global-state"><a class="anchor" href="#estado-global-global-state">Estado global (Global State)</a></h2>
<p>El estado global debe ser accesible desde cualquier parte de la aplicación. La información de autenticación, el tema, el idioma o las notificaciones (toasts) son posibles candidatos.</p>
<p>La diferencia entre el estado local y el global no se reduce a «dónde viven». Lo que cambia es <strong>el contrato de acceso</strong>. El estado local establece el contrato de que <strong>«solo tiene sentido dentro de este componente»</strong>; el global, en cambio, publica en todo el código el contrato de que <strong>«este valor puede consultarse con este nombre desde cualquier lugar de la aplicación»</strong>. La esencia del estado global es que ese contrato resulta costoso.</p>
<p>Crear un estado global equivale, en realidad, a añadir <strong>una dependencia implícita en toda la aplicación</strong>.</p>
<h2 id="estado-del-servidor-server-state"><a class="anchor" href="#estado-del-servidor-server-state">Estado del servidor (Server State)</a></h2>
<p>Guardamos en el estado del cliente los datos recibidos mediante una API, gestionamos manualmente la carga y los errores con valores boolean y terminamos preguntándonos: <strong>«¿por qué escribo siempre el mismo boilerplate?»</strong>.</p>
<p>Tanner Linsley, principal responsable de TanStack, afirma: <strong>«Client State es síncrono y predecible. Server State es asíncrono, se comparte entre varios componentes y exige gestionar con cuidado el caching, las actualizaciones en background y los estados de error»</strong>. Es decir, Server State es <strong>una especie esencialmente distinta</strong> de Client State. No deben tratarse con la misma herramienta.</p>
<p>La complejidad de Server State no se debe a las herramientas, sino a <strong>la propia naturaleza de los datos</strong>.</p>
<p>Los datos que ve el cliente pertenecen al servidor. Lo que conserva el cliente no es más que <strong>un snapshot de un momento concreto</strong>. Con el paso del tiempo, esos datos acumulan staleness. Además, son asíncronos, pueden fallar y atraviesan estados como pending, error y success.</p>
<p>La propiedad esencial más importante es que <strong>nada garantiza que las respuestas regresen en el mismo orden en que se enviaron las solicitudes</strong>. Imaginemos que escribimos rápidamente «react» en un campo de búsqueda. Las solicitudes r → re → rea → reac → react se envían en ese orden, pero si la respuesta de «react» llega primero y después llega la de «rea», la pantalla mostrará los resultados de «rea». Para evitar este problema hay que ocuparse de los <strong>riesgos de concurrencia (race conditions)</strong>, implementando cada vez a mano un AbortController o el seguimiento de los ID de solicitud.</p>
<h2 id="estado-de-formulario-form-state"><a class="anchor" href="#estado-de-formulario-form-state">Estado de formulario (Form State)</a></h2>
<p>Los formularios albergan un tipo de estado peculiar. Mientras el usuario escribe, cambia intensamente; pero, una vez enviado, normalmente desaparece. No se comparte con ningún otro lugar y, en la mayoría de los casos, tampoco tiene otro destino en el que almacenarse.</p>
<p>El problema es que ese «cambio intenso» resulta costoso. Si cada pulsación provoca un nuevo renderizado de React, en los formularios grandes el retraso al escribir puede llegar a ser perceptible. Además, un formulario no se limita a «guardar valores». Dentro de él conviven y cambian al mismo tiempo muchos tipos de estado: <strong>validación, dirty check, estado de envío, mensajes de error y flujos de varios pasos</strong>.</p>
<p>En un formulario de varios pasos, como un proceso de pago en tres etapas, se espera que <strong>«el progreso sobreviva incluso a una recarga a mitad del proceso»</strong>. Si mantenemos los valores del formulario únicamente con useState, la recarga los eliminará todos. Lo natural es almacenarlos en <strong>sessionStorage</strong> (persistencia temporal por pestaña) o en la <strong>URL</strong> (para pasos que puedan compartirse). Es decir, según los requisitos de su ciclo de vida, Form State se combina con <strong>External State</strong> o <strong>URL State</strong>.</p>
<h2 id="estado-de-url-url-state"><a class="anchor" href="#estado-de-url-url-state">Estado de URL (URL State)</a></h2>
<p>Supongamos que en una página de búsqueda estamos filtrando por categoría, orden y número de página. Si mantenemos esos estados con useState, aparecen tres problemas a la vez.</p>
<ul>
<li>Al recargar, todos los filtros se reinician</li>
<li>Aunque compartamos la URL con otra persona, esta verá la página sin los filtros aplicados</li>
<li>Al pulsar «Atrás», no regresaremos a los filtros anteriores</li>
</ul>
<p>Para resolver estos problemas, <strong>resulta natural colocar el estado en la URL</strong>. La URL es, por sí misma, un almacenamiento persistente gratuito que ya admite recargas, uso compartido e historial.</p>
<pre><code>/products?category=shoes&#x26;sort=price-desc&#x26;page=2
</code></pre>
<p>Esa única línea contiene el estado completo de <strong>«la segunda página de la categoría de zapatos, ordenada por precio descendente»</strong>. No hace falta mantenerlo por separado con useState.</p>
<p>Entonces, ¿cuándo conviene gestionar el estado en la URL? <strong>La URL es una interfaz pública</strong>. No debemos incluir en ella contraseñas, tokens de autenticación ni notas temporales que el usuario no quiera mostrar a otras personas. Tampoco conviene insertar directamente valores que cambian con demasiada frecuencia —como un término de búsqueda que se actualiza con cada pulsación—, porque el stack del historial se llenará de basura. En esos casos debemos aplicar el cambio después de un debounce y reservar <code>push</code> para cuando corresponda, usando <code>replace</code> en las actualizaciones que no deban añadir una entrada al historial.</p>
<p>Los valores de la URL son <strong>siempre strings</strong>. Los números, valores boolean, arrays y objetos deben pasar por un proceso de serialización y deserialización. Además, la URL debe seguir las reglas de <a href="https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams" target="_blank" rel="noopener noreferrer">percent-encoding</a>, por lo que caracteres como <code>&#x26;</code>, <code>=</code>, el coreano o los espacios reciben un tratamiento especial. Implementarlo manualmente una y otra vez pronto se convierte en una fuente de errores.</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>Librerías como <a href="https://nuqs.dev/" target="_blank" rel="noopener noreferrer">nuqs</a> resuelven ambos problemas mediante el concepto de <strong>parser</strong>. Parsers como <code>parseAsInteger</code>, <code>parseAsBoolean</code> y <code>parseAsJson</code> se encargan a la vez de la serialización, la deserialización y los tipos. Son compatibles con la mayoría de los entornos, incluidos Next.js (tanto App Router como Pages Router), React Router v6/v7, TanStack Router y Remix.</p>
<p>¿Significa eso que podemos insertar en la URL todo el estado que queramos? Al margen de los problemas de serialización y tipos, queda una última restricción. <a href="https://datatracker.ietf.org/doc/html/rfc7230" target="_blank" rel="noopener noreferrer">RFC 7230</a> no fija un límite exacto, pero recomienda que «los servidores admitan al menos 8.000 octetos» (unidad que designa inequívocamente un byte formado por ocho bits en redes y comunicaciones de datos). Los límites también varían entre navegadores: los navegadores modernos suelen admitir desde 8 KB hasta decenas de miles de caracteres, pero <strong>el procesamiento de OG y enlaces compartidos de buscadores y redes sociales, así como algunos gateways, puede truncarlos alrededor de los 2 KB</strong>. Por tanto, no introduzcamos datos ilimitados en la URL. Lo seguro es conservar allí únicamente <strong>los filtros esenciales que deban poder compartirse</strong> y delegar el resto en sessionStorage o en almacenamiento del lado del servidor.</p>
<h2 id="estado-externo-external-state"><a class="anchor" href="#estado-externo-external-state">Estado externo (External State)</a></h2>
<p>React solo conoce el estado que hay en su interior. Sin embargo, nuestra aplicación se comunica sin cesar con el mundo exterior a React. Los estados que viven en ese mundo sobreviven y cambian con independencia del ciclo de vida de React. Aquí, External State se refiere a <strong>Cookie, localStorage,sessionStorage,IndexedDB</strong>.</p>
<p>¿Cómo elegir el almacenamiento apropiado? Suelo evaluarlo desde cuatro perspectivas: <strong>duración, capacidad, sincronía y seguridad</strong>.</p>
<p>Para los <strong>tokens de autenticación</strong>, la <a href="https://owasp.org/www-community/HttpOnly" target="_blank" rel="noopener noreferrer">recomendación de OWASP</a> prioriza las <strong>cookies HttpOnly + Secure</strong>. Como JavaScript puede acceder a localStorage, <strong>en cuanto se produce una vulnerabilidad XSS, el token queda directamente expuesto</strong>. Algunas guías de seguridad recomiendan un patrón híbrido: <strong>guardar el access token en memoria y el refresh token en una cookie HttpOnly</strong>. Para datos persistentes, no sensibles y que cambian con poca frecuencia se utiliza localStorage; para datos que deben desaparecer junto con la pestaña, sessionStorage. IndexedDB suele emplearse para caché offline, grandes volúmenes de datos y archivos.</p>
<p>Cookie y Web Storage (local/session) <strong>solo almacenan strings</strong>. Para guardar un objeto hay que pasar por <code>JSON.stringify</code>/<code>JSON.parse</code>. Pero JSON tiene limitaciones.</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> se convierte en string al hacer un round trip por JSON, mientras que <code>Map</code>, <code>Set</code> y <code>undefined</code> pueden perder datos. Con el comportamiento predeterminado, <code>BigInt</code> hace que <code>JSON.stringify</code> lance un <code>TypeError</code>, por lo que la serialización falla por completo. Al guardar objetos en almacenamiento externo, debemos tener siempre presente <strong>qué tipos pueden desaparecer, transformarse o hacer fallar la serialización</strong> y añadir un adaptador cuando sea necesario.</p>
<p>La verdadera dificultad de External State es que <strong>React no detecta automáticamente sus cambios</strong>. Aunque escribamos un valor en localStorage, los componentes de React no vuelven a renderizarse. Normalmente existen tres patrones para resolverlo.</p>
<ul>
<li><strong>Envolverlo en un custom hook (useLocalStorage) y sincronizar el estado externo con el estado de React.</strong> Es una solución ligera, pero si la implementamos nosotros mismos debemos cubrir todos los casos límite: múltiples pestañas, SSR, tearing, etc.</li>
<li>Usar el hook <code>useSyncExternalStore</code>, incorporado en React 18, para <strong>«sincronizarnos con un estado externo a React»</strong>. Esto permite <strong>garantizar que no se produzca tearing durante el renderizado concurrente</strong>. Es la herramienta estándar para conectar localStorage, las API del navegador y los stores externos.</li>
<li>Aprovechar librerías existentes, ya que las librerías de estado ofrecen como funcionalidad de primera clase la integración con almacenamiento externo, como el middleware <code>persist</code> de Zustand o <code>atomWithStorage</code> de Jotai.</li>
</ul>
<p>Añadamos otro criterio: <strong>en el momento en que llevamos External State a React, la responsabilidad de sincronizarlo recae sobre nosotros</strong>. ¿Qué ocurre si se actualiza desde otra pestaña? ¿Si el servidor modifica una cookie? ¿Si el usuario manipula localStorage directamente desde las herramientas de desarrollo del navegador? Estas situaciones suelen convertirse en algunas de las mayores fuentes de errores.</p>
<h2 id="guard-del-estado-state-guard"><a class="anchor" href="#guard-del-estado-state-guard">Guard del estado (State Guard)</a></h2>
<p>La última categoría es algo diferente. No es estado en sí mismo, sino <strong>la lógica que bloquea, permite o valida un flujo a partir de una combinación de estados</strong>.</p>
<p>El ejemplo más habitual es el <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>Aquí, el estado <code>isAuthenticated</code> controla el flujo de routing. Eso es precisamente la lógica de un guard. Existen varios tipos: Auth Guard (autenticación), guards de autorización (roles o permisos específicos), guards de flujo (ramificación al entrar) y guards de validación (activación de etapas), entre otros.</p>
<p>La lógica de guards tiende a concentrarse en un único lugar. Es habitual que un componente reúna condiciones como <strong>«si no ha iniciado sesión, ir a la página de login; si no tiene permisos, mostrar un 403; si el carrito está vacío, ir a la página de productos; si el usuario está suspendido, mostrar el aviso de suspensión»</strong>. Cuanto más crece el guard, más difícil resulta depurar qué condición bloqueó el flujo y dónde.</p>
<p>Un buen guard <strong>solo comprueba una cosa</strong>. La combinación se realiza mediante 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>Cada guard toma una única decisión y la composición queda a cargo de la estructura del árbol. Añadir un guard nuevo no requiere modificar los existentes.</p>
<p>Al diseñar guards, hay algo que debemos pensar incluso más que en el bloqueo: <strong>decidir adónde enviar al usuario y qué hacer después</strong>. Un guard que solo bloquea y no ofrece fallback termina en una pantalla en blanco o un spinner infinito.</p>
<p>El error más común consiste en que <strong>«el contenido protegido parpadea brevemente antes de que termine la comprobación asíncrona del guard»</strong>. La validación del token de autenticación y la consulta de permisos suelen ser asíncronas; durante ese intervalo, <code>isAuthenticated</code> puede adoptar temporalmente el valor <code>undefined</code> o <code>false</code>. <strong>Si no tratamos explícitamente el estado de carga, el contenido protegido puede quedar expuesto durante ese instante o el usuario puede ser redirigido erróneamente a la página de login</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>Al implementar guards de autorización se utilizan habitualmente dos modelos.</p>
<ul>
<li><strong>RBAC (Role-Based Access Control)</strong>: asigna permisos por rol. Por ejemplo: «admin puede ver la información de todos los usuarios». Es sencillo y rápido, pero el número de roles se dispara a medida que aumenta la granularidad</li>
<li><strong>ABAC (Attribute-Based Access Control)</strong>: determina los permisos mediante una combinación de atributos. Por ejemplo: «si el usuario es autor de la publicación, pertenece al mismo equipo o es admin». Tiene una gran capacidad expresiva, pero es difícil de implementar y depurar</li>
</ul>
<p>Como muestra la <a href="https://tanstack.com/router/v1/docs/framework/react/how-to/setup-rbac" target="_blank" rel="noopener noreferrer">guía de RBAC de TanStack Router</a>, se recomienda el patrón de colocar los guards en <code>beforeLoad</code>, en el nivel del router. La clave es que <strong>las comprobaciones de autorización no estén dispersas por el código, sino que puedan expresarse como datos (listas de roles y permisos)</strong>. Así, un cambio en la política de permisos se limita a un <strong>cambio de datos</strong>.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>Recapitulemos. La gestión del estado no es difícil porque las librerías lo sean. Es difícil porque <strong>a menudo olvidamos que existen distintos tipos de estado</strong> y porque resulta fácil pasar por alto que cada tipo exige herramientas y formas de pensar diferentes.</p>
<p>Mantener el estado local lo más cerca posible; cuestionar una vez más si el estado global es realmente global; tratar Server State como caché; separar los formularios del dominio; aprovechar la URL de forma más activa; ser conscientes de la responsabilidad que implica el almacenamiento externo; y dividir los guards en unidades pequeñas que puedan componerse. Esos son los fundamentos para trabajar con las siete categorías.</p>
<p>Y el criterio que opera por encima de ellas puede condensarse, en última instancia, en cuatro preguntas.</p>
<ul>
<li>¿Dónde está la Single Source of Truth de estos datos?</li>
<li>¿Es un valor que puede calcularse o un valor que realmente debemos almacenar?</li>
<li>¿Hay alguna combinación imposible entre estos estados?</li>
<li>¿Este estado debe estar realmente en esta ubicación?</li>
</ul>
<p>Plantearnos estas preguntas cada vez que construimos una pantalla nueva, revisamos una PR o recibimos código generado por la IA es, a mi juicio, la forma más segura de desarrollar criterio e intuición.</p>
<p>Como decía al principio, la IA permanecerá a nuestro lado durante mucho tiempo. Cada vez dedicaremos menos tiempo a revisar el código línea por línea. Pero, precisamente por eso, será más valiosa la capacidad de responder a pequeñas preguntas como <strong>«¿dónde debe residir este estado?»</strong>. Pedirle a la IA «añade aquí otro useState» es fácil. Sin embargo, comprender qué hilo añade esa línea a la telaraña de nuestra aplicación depende únicamente del criterio de quien lee el código.</p>
<p>No existe una única respuesta correcta. Pero hay una diferencia evidente entre <strong>«crear estado sin saber qué es el estado»</strong> y <strong>«crearlo siendo conscientes de su tipo y ubicación»</strong>. Espero que, la próxima vez que los lectores de este artículo vayan a escribir una línea de <code>useState</code>, se detengan un instante y se pregunten: «¿a qué categoría de estado pertenece esto?».</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>gestión-del-estado</category>
            <category>React</category>
            <category>arquitectura</category>
        </item>
        <item>
            <title><![CDATA[Modelo de dominio]]></title>
            <link>https://hooninedev.com/es/260418</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260418</guid>
            <pubDate>Sat, 18 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta entrada quiero hablar de dónde debe vivir la lógica de dominio en el frontend. Está pensada para desarrolladores frontend cuyas reglas de negocio, como el cálculo de impuestos o la comprobació...]]></description>
            <content:encoded><![CDATA[<p>En esta entrada quiero hablar de <strong>dónde debe vivir la lógica de dominio en el frontend</strong>.</p>
<p>Está pensada para desarrolladores frontend cuyas reglas de negocio, como el cálculo de impuestos o la comprobación del estado de una declaración, están repartidas entre componentes, hooks y utils, de modo que cada cambio de regla obliga a buscar y corregir varios archivos. Al terminar, sabrás cómo mover esas reglas a funciones puras que no dependen de React y con qué criterio decidir dónde detener esa separación.</p>
<p>Todos los ejemplos usan el cálculo del impuesto sobre la renta, un dominio que me interesa.</p>
<h2 id="dónde-debe-estar-la-lógica-de-dominio-en-el-frontend"><a class="anchor" href="#dónde-debe-estar-la-lógica-de-dominio-en-el-frontend">¿Dónde debe estar la lógica de dominio en el frontend?</a></h2>
<p>Antes de responder, pongámonos de acuerdo en un término. En esta entrada, un <strong>modelo de dominio</strong> reúne los conceptos de negocio junto con las reglas que operan sobre ellos. Es distinto de un <strong>modelo de datos</strong>, como el tipo de una respuesta de la API, que solo define la forma en que viajan los datos. Esta diferencia es también la razón por la que, cuando los componentes dependen directamente de la estructura de la respuesta, todo el frontend se tambalea cada vez que cambia el esquema del backend. En qué se diferencian los cuatro términos (dominio, modelo de dominio, objeto de dominio y modelo de objetos de dominio) lo explico aparte en <a href="/260413" target="_blank" rel="noopener noreferrer">Términos de dominio</a>.</p>
<p><a href="https://khalilstemmler.com/about/" target="_blank" rel="noopener noreferrer">Khalil Stemmler</a>, muy interesado en el diseño de software, sostuvo al principio que «la lógica de negocio no pertenece al frontend», pero más adelante revisó su postura y afirmó que «casi todo lo que hacemos arquitectónicamente en el backend también podemos y debemos hacerlo en el frontend».</p>
<p>Estoy de acuerdo. Por supuesto, el frontend no debe convertirse en la <strong>única fuente de verdad (Single Source of Truth)</strong> de la lógica de negocio. Ese es el papel del backend. Pero en el frontend también existe, sin duda, <strong>lógica de dominio propia del frontend</strong>.</p>
<p>Pensemos en un caso en el que «hay que mostrar en tiempo real la devolución estimada en función de la información introducida por el usuario». Si esta lógica de cálculo solo existe en el backend, habría que llamar a la API cada vez que el usuario corrigiese un solo dígito del importe de sus ingresos. La UI se detendría durante todo el viaje de ida y vuelta por la red y, si el usuario escribe rápido, se dispararía una cantidad enorme de peticiones innecesarias. Incluso con debounce, un retraso de varios cientos de milisegundos basta para romper la experiencia de una «vista previa en tiempo real». <strong>En última instancia, el frontend no tiene más remedio que realizar directamente los cálculos que requieren una respuesta inmediata, por lo que existe lógica que solo puede ejecutarse en el frontend.</strong></p>
<h3 id="cuando-la-lógica-de-dominio-se-mezcla-con-el-componente"><a class="anchor" href="#cuando-la-lógica-de-dominio-se-mezcla-con-el-componente">Cuando la lógica de dominio se mezcla con el componente</a></h3>
<p>Tomemos como ejemplo una pantalla de vista previa del impuesto sobre la renta global. Cuando el usuario introduce la información sobre sus ingresos, se muestra en tiempo real la cuota estimada. Este es un ejemplo habitual de código en el que se mezclan la lógica de dominio y la lógica de UI.</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>¿Se aprecia el problema? Las <strong>reglas de negocio fijadas por la legislación fiscal</strong> —«deducción personal de 1,5 millones de wones por persona», «ocho tramos impositivos progresivos» y «retención del 3,3 %»— están incrustadas directamente en un componente de React. La legislación fiscal cambia cada año; si estas reglas están dispersas por los componentes, cada reforma obliga a buscar todos los lugares que hay que modificar. Si además existen escenarios E2E gestionados por el equipo de QA, el coste de las pruebas tampoco será menor.</p>
<p>Al final, resulta difícil distinguir la lógica de vista de la lógica de negocio, y el código acaba enredado entre innumerables condicionales y hooks personalizados.</p>
<h3 id="separemos-la-lógica-de-dominio"><a class="anchor" href="#separemos-la-lógica-de-dominio">Separemos la lógica de dominio</a></h3>
<p>Tomemos prestado un principio central del enfoque de Clean Architecture de Alex Bespoyasov: separar la lógica de dominio en <strong>funciones puras que no dependan de ningún framework</strong>.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>El dominio es el núcleo que distingue una aplicación de otra. Puede entenderse como aquello que no cambiaría si migrásemos de React a 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>Refactoricemos el ejemplo anterior del cálculo de impuestos.</p>
<p>Primero definimos los tipos y las reglas del dominio para cohesionar la información relacionada.</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>A continuación, separamos la lógica de dominio en funciones puras.</p>
<p>Separamos en la función <code>computeFullTax</code> la lógica anterior para calcular los ingresos, las deducciones, la base imponible, la cuota y la devolución. A su vez, dividimos cada etapa en pequeñas funciones puras. Si inferimos el tipo del resultado con <code>ReturnType&#x3C;typeof computeFullTax></code>, no hace falta declarar otra interfaz.</p>
<p>Después, el componente se limita a «usar» la lógica de dominio.</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>¿Qué ha cambiado?</p>
<ul>
<li>La <strong>tabla de ocho tramos impositivos progresivos</strong> (<code>TAX_BRACKETS</code>) está reunida en un único lugar, por lo que, cuando cambie la legislación fiscal, solo habrá que modificar <code>domain/tax.ts</code>.</li>
<li>El <strong>flujo de cálculo</strong> está cohesionado en una sola función, <code>computeFullTax</code>, lo que permite ver de un vistazo el proceso completo. (Se ha agrupado para simplificar el ejemplo, pero en un proyecto real conviene dividirlo más según su propósito: cálculo de ingresos, cálculo de deducciones, cálculo de la cuota, etc.).</li>
<li>El <strong>componente se concentra únicamente en «cómo mostrarlo»</strong>. Aunque cambien los tipos impositivos, no hace falta modificar el componente.</li>
<li>Aunque se migre de React a otro framework, <code>domain/tax.ts</code> <strong>no cambia</strong>.</li>
</ul>
<p>Cuando se separa la lógica de dominio, las pruebas se vuelven sorprendentemente sencillas. Esto es especialmente importante en el dominio fiscal, donde <strong>la precisión de los cálculos es, literalmente, el dinero del usuario</strong>.</p>
<p>Las funciones puras que contienen cálculos fiscales no necesitan React Testing Library, ni <code>render</code>, ni <code>screen.getByText</code>. Basta con proporcionar una entrada y comprobar la salida. Casos como «tipo del 6 % hasta 14 millones de wones», «si la base imponible es 0 wones, la cuota también es 0» o «devolución para un autónomo con 30 millones de wones de ingresos» pueden expresarse con un <code>it</code> de una sola línea. Las pruebas unitarias del dominio establecen de forma natural el criterio para separar componentes y, además, el código de prueba actúa como documentación.</p>
<h2 id="modelo-de-dominio-anémico-anemic-domain-model"><a class="anchor" href="#modelo-de-dominio-anémico-anemic-domain-model">Modelo de dominio anémico (Anemic Domain Model)</a></h2>
<p>En el apartado anterior separamos la <strong>lógica de cálculo</strong>. Sin embargo, la lógica de dominio también incluye <strong>reglas de transición de estado</strong> y <strong>comprobaciones de permisos</strong>. Preguntas como «¿se puede editar ahora esta declaración?», «¿se puede presentar?» o «¿se puede cambiar el tipo de reclamación?» pertenecen a esta categoría. Al separar estas reglas es fácil caer en una trampa que Martin Fowler denominó <strong>modelo de dominio anémico (Anemic Domain Model)</strong>.</p>
<p>Un modelo de dominio anémico es aquel en el que <strong>los tipos están bien definidos en el lenguaje del dominio, pero las reglas que operan sobre ellos se han dispersado fuera del dominio</strong>. Veamos como ejemplo el dominio de una declaración de impuestos (Filing). El tipo está limpio.</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>Pero las reglas para decidir y realizar transiciones sobre este tipo están incrustadas en otros lugares.</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>La misma regla de dominio existe con formas distintas en tres lugares: utils, un componente y un hook. Si en este estado llega un requisito como «cambian las condiciones para presentar una reclamación», habrá que recorrer el código en busca de todos los lugares que deben modificarse, y cualquier omisión provocará una decisión incorrecta en alguna parte del sitio. Fowler criticó este tipo de código por ser <strong>«poco más que código procedimental revestido con una apariencia orientada a objetos»</strong>.</p>
<p>La solución es la misma que aplicamos a la lógica de cálculo en el apartado anterior: <strong>poner las reglas junto al tipo</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>Ahora las reglas relacionadas con las declaraciones se gestionan en un único lugar, <code>domain/filing.ts</code>. Cualquier componente puede llamar a <code>canAmend(filing)</code> y, si cambia una regla, basta con modificar este archivo. La clave es <strong>entender el tipo y las reglas que operan sobre él como una sola unidad</strong>. Dejar solo el tipo en la carpeta del dominio y extraer las reglas a utils es una separación parcial que puede parecer limpia, pero sigue siendo anémica.</p>
<h2 id="capa-de-transformación-entre-la-respuesta-de-la-api-y-el-modelo-de-dominio"><a class="anchor" href="#capa-de-transformación-entre-la-respuesta-de-la-api-y-el-modelo-de-dominio">Capa de transformación entre la respuesta de la API y el modelo de dominio</a></h2>
<p>En un proyecto real hay que tener en cuenta otra cuestión: la estructura de las respuestas de la API del backend no siempre coincide con el modelo de dominio del frontend. Esto es aún más cierto en un servicio fiscal conectado con organismos públicos. Los datos de integración con Hometax, el portal de la Agencia Tributaria Nacional de Corea, están llenos de abreviaturas y códigos, por lo que es poco probable que lleguen con la misma forma que el modelo de dominio del frontend.</p>
<p>Para eso necesitamos una <strong>capa de transformación (Mapper)</strong>. En lugar de llevar el tipo de la respuesta de la API directamente hasta el componente, primero lo depuramos y lo convertimos en un tipo de dominio. Basta con una función pura.</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>De este modo, las abreviaturas y clasificaciones basadas en códigos de la respuesta de la API, como <code>총수입금액</code> y <code>경비율</code>, se transforman <strong>en un único lugar</strong> para adaptarlas al dominio del frontend. Los valores que deben desplegarse como enum, como el código del tipo de ingreso, pueden resolverse con una pequeña lookup table dentro del mapper. Aunque cambien los nombres de los campos de la API de Hometax, solo habrá que modificar el mapper.</p>
<h2 id="funciones-utilitarias-y-lógica-de-dominio"><a class="anchor" href="#funciones-utilitarias-y-lógica-de-dominio">Funciones utilitarias y lógica de dominio</a></h2>
<p>Al separar la lógica de dominio surge inevitablemente una pregunta: <strong>«¿esto no es una función utilitaria?»</strong></p>
<p>Veamos, por ejemplo, estas dos funciones.</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> es pura <strong>lógica de presentación (Presentation)</strong> que transforma un número en texto. Añadir la unidad «won» y los separadores de miles no es una regla de negocio, sino una decisión sobre cómo mostrar el valor al usuario. En cambio, <code>calculateTax</code> contiene una <strong>regla de negocio basada en la legislación fiscal</strong>: «aplicar ocho tramos impositivos progresivos». Es una regla del dominio que debe aplicarse de la misma forma incluso sin UI.</p>
<p>Este es el criterio que uso en la práctica.</p>
<blockquote>
<p><strong>Si esta lógica desaparece, ¿se rompe el negocio o solo se rompe la pantalla?</strong></p>
</blockquote>
<p>Si se rompe el negocio, es lógica de dominio; si solo se rompe la pantalla, es lógica de presentación. Esta pregunta permite trazar la mayoría de los límites.</p>
<table>
<thead>
<tr>
<th>Criterio</th>
<th>Lógica de dominio</th>
<th>Lógica utilitaria/de presentación</th>
</tr>
</thead>
<tbody>
<tr>
<td>¿Qué se rompe si falta?</td>
<td>El cálculo de impuestos</td>
<td>La pantalla (UI) se ve mal</td>
</tr>
<tr>
<td>¿Qué ocurre si cambia el framework?</td>
<td>Se conserva</td>
<td>Puede cambiar</td>
</tr>
<tr>
<td>¿Está especificada como requisito?</td>
<td>«Base imponible × tipo − deducción progresiva»</td>
<td>«Los importes llevan separadores»</td>
</tr>
<tr>
<td>¿Existe la misma lógica en el backend?</td>
<td>Existe o debería existir</td>
<td>No (solo concierne al frontend)</td>
</tr>
</tbody>
</table>
<p>Pero la realidad no es tan limpia. El caso más difícil es el de la <strong>lógica que parece de dominio, pero en realidad es de presentación</strong>.</p>
<p>Veamos el siguiente código. Como recibe un concepto de dominio llamado FilingStatus, se ha clasificado como lógica de dominio. Pero ¿lo es realmente?</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>Aunque <code>getStatusBadgeColor</code> y <code>getStatusDisplayText</code> usan el concepto de dominio <code>FilingStatus</code>, su función es la <strong>presentación en pantalla</strong>. Si cambia el color de la insignia, el negocio no se rompe en absoluto. Si ponemos estas funciones en <code>domain/filing.ts</code>, el módulo de dominio crecerá cada vez más y la auténtica lógica de dominio acabará mezclada con la lógica de presentación.</p>
<h3 id="separar-el-modelo-de-dominio-y-el-viewmodel"><a class="anchor" href="#separar-el-modelo-de-dominio-y-el-viewmodel">Separar el modelo de dominio y el ViewModel</a></h3>
<p>Hay una forma práctica de resolver este problema: <strong>separar el ViewModel en un archivo distinto dentro de la misma carpeta del dominio</strong>. En lugar de <code>.ui.ts</code>, usar el nombre <code>.viewModel.ts</code> enlaza de forma natural con el concepto de ViewModel del patrón MVVM. El propio nombre deja claro su papel como «capa que transforma los datos de dominio para adaptarlos a la pantalla».</p>
<pre><code>domains/
└── filing/
    ├── filing.ts              # 순수 도메인 모델 + 도메인 로직
    ├── filing.viewModel.ts    # ViewModel (표현 변환 계층)
    ├── filing.test.ts         # 도메인 로직 테스트
    └── filingMapper.ts        # API ↔ 도메인 변환
</code></pre>
<p>Movemos tal cual <code>getStatusBadgeColor</code> y <code>getStatusDisplayText</code> a <code>filing.viewModel.ts</code>. También reunimos aquí transformaciones como <code>getFilingTypeLabel(type: FilingType): string</code>, que convierte los tipos de declaración en etiquetas coreanas. <code>filing.ts</code> se ocupa solo de las reglas de negocio y <code>filing.viewModel.ts</code>, solo de su presentación en pantalla.</p>
<p>La clave es la <strong>dirección de las dependencias</strong>. <code>filing.viewModel.ts</code> importa <code>filing.ts</code>, pero <code>filing.ts</code> nunca importa <code>filing.viewModel.ts</code>. El dominio no conoce la presentación; la presentación sí conoce el dominio. Puede verse como una versión reducida de la regla de dependencias (Dependency Rule) de Robert C. Martin.</p>
<p>He colocado en la misma carpeta los archivos que cambian juntos porque considero que deben estar en el mismo directorio. Si se añade un valor nuevo al tipo <code>FilingStatus</code> —por ejemplo, <code>'rejected'</code>—, habrá que modificar tanto <code>filing.ts</code> como <code>filing.viewModel.ts</code>. Al encontrarse en la misma carpeta, el alcance del cambio resulta evidente.</p>
<h2 id="límites-y-cohesión"><a class="anchor" href="#límites-y-cohesión">Límites y cohesión</a></h2>
<p>Tan importante como separar la lógica de dominio es decidir <strong>dónde trazar los límites</strong>. Estos son algunos de los problemas al definir límites que encuentro con frecuencia en proyectos reales.</p>
<p>Los datos que maneja un frontend proceden, a grandes rasgos, de cuatro fuentes.</p>
<ul>
<li><strong>Datos del servidor</strong>: recibidos como respuesta de una API.</li>
<li><strong>Datos derivados</strong>: calculados a partir de los datos del servidor.</li>
<li><strong>Estado de la UI</strong>: destinado a controlar la pantalla y las interacciones del usuario.</li>
<li><strong>Entrada del usuario</strong>: datos que se están introduciendo en un formulario.</li>
</ul>
<p>Si mezclamos los cuatro en un único tipo, contaminamos el modelo de dominio.</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>Este tipo mete en el mismo recipiente conceptos de dominio, estado de la UI y datos temporales. Cada vez que cambia <code>activeStep</code>, es como si se actualizase el dominio de la declaración. (Cambiar de paso en un formulario no es un evento de negocio).</p>
<p>La mejora consiste en separar los tipos según sus límites. El <strong>modelo de dominio</strong> solo contiene conceptos de negocio como <code>id</code>, <code>status</code> y <code>determinedTax</code>; el <strong>estado de la UI</strong> (<code>FilingFormViewState</code>) contiene únicamente controles de pantalla como <code>isExpanded</code> y <code>activeStep</code>; y el <strong>estado del formulario</strong> (<code>DeductionEditForm</code>), solo los datos temporales que se están introduciendo.</p>
<p>Así, cada tipo tiene <strong>un único motivo para cambiar</strong>. Los tipos de dominio solo se modifican cuando cambia la legislación fiscal; el estado de la UI, cuando cambia el diseño de la pantalla; y el estado del formulario, cuando cambia la UX de entrada.</p>
<h3 id="mantengamos-juntas-las-cosas-que-cambian-juntas"><a class="anchor" href="#mantengamos-juntas-las-cosas-que-cambian-juntas">Mantengamos juntas las cosas que cambian juntas</a></h3>
<p>En el DDD de Eric Evans existe el concepto de <strong>Aggregate (agregado)</strong>: «tratar un grupo de objetos relacionados como una sola unidad». No hace falta aplicarlo literalmente en el frontend, pero sí merece la pena adoptar su principio central: <strong>mantener juntos los datos y las reglas que cambian juntos</strong>.</p>
<p>En un servicio fiscal, por ejemplo, <code>Income</code> (ingresos) y <code>ExpenseRate</code> (coeficiente de gastos) siempre cambian juntos. Si cambia el tipo de ingreso, también cambia el coeficiente aplicable y se ve afectado el cálculo de la renta global. Por tanto, conviene cohesionarlos en un solo archivo, <code>domain/tax.ts</code>.</p>
<p>En cambio, <code>TaxFiling</code> (declaración) puede cambiar con independencia del cálculo de la cuota. Aunque cambien las reglas de transición de estado de una declaración, la lógica para calcular los tipos impositivos no se ve afectada. Por tanto, es correcto separarla en <code>domain/filing.ts</code>.</p>
<pre><code>이렇게 묻자: "A가 변할 때 B도 반드시 변해야 하는가?"
  → Yes: 같은 모듈에 둔다 (Income + ExpenseRate + TaxBracket)
  → No: 분리한다 (Tax 계산 ↔ Filing 상태관리)
</code></pre>
<h2 id="class-frente-a-estilo-funcional"><a class="anchor" href="#class-frente-a-estilo-funcional">Class frente a estilo funcional</a></h2>
<p>Llegados a este punto puede surgir una pregunta fundamental. Todos los ejemplos anteriores combinan <code>interface</code> y funciones puras; ¿no sería más natural expresar el dominio con Class para lograr una mayor cohesión?</p>
<p>Es cierto. Cuando se expresa un dominio mediante Class, los datos y el comportamiento quedan agrupados en un mismo objeto, por lo que la cohesión se hace visible directamente en la estructura del código.</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>Con Class, el comportamiento pertenece a los datos y el sujeto queda claro allí donde se utiliza. <code>filing.canAmend()</code> resulta intuitivo, como una frase en lenguaje natural. El sujeto (filing) y el verbo (canAmend) están unidos de forma explícita. Del mismo modo, al escribir <code>jihoon.eat('감자탕')</code> se lee de inmediato que «Jihoon come gamjatang».</p>
<p>En el estilo funcional, en cambio, queda así.</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>En el enfoque funcional, los datos existen fuera de la función. Las dos líneas anteriores reciben <code>filing</code> como argumento y ejecutan alguna operación. La función <code>eat</code> recibe <code>jihoon</code> y <code>감자탕</code> como datos y realiza la acción.</p>
<p>Como resultado, la relación entre sujeto y verbo es más débil. Para saber que la función <code>canAmend</code> está relacionada con <code>TaxFiling</code>, hay que abrir el archivo o consultar su firma de tipos. Si en un mismo archivo se mezclan funciones como <code>canAmend(filing)</code>, <code>canEdit(filing)</code> y <code>calculateTax(taxableBase)</code>, puede resultar difícil saber de un vistazo a qué dominio pertenece cada una.</p>
<h3 id="entonces-deberíamos-usar-class"><a class="anchor" href="#entonces-deberíamos-usar-class">Entonces, ¿deberíamos usar Class?</a></h3>
<p>Sinceramente, la respuesta es <strong>«depende de la situación»</strong>. Sin embargo, según mi experiencia, hay razones prácticas por las que Class no es una solución universal en un entorno React + TypeScript.</p>
<h4 id="fricción-con-la-gestión-del-estado-de-react"><a class="anchor" href="#fricción-con-la-gestión-del-estado-de-react">Fricción con la gestión del estado de React</a></h4>
<p>La gestión del estado de React encaja de forma más natural con <strong>Plain Object</strong>. Aunque <code>useState</code> y <code>useReducer</code> pueden contener técnicamente cualquier valor, y Redux DevTools no elimina por sí mismo el prototipo de una instancia de Class, cuando el middleware de persistencia de Redux/Zustand guarda y restaura el estado como JSON, una instancia de Class pierde sus métodos y su prototipo en el ciclo <code>JSON.stringify</code> → <code>JSON.parse</code> y queda reducida a un plain object. El límite de props entre React Server Component y Client Component impone una restricción distinta: solo admite valores serializables (serializable) compatibles, por lo que una instancia arbitraria de Class no puede atravesarlo.</p>
<p>Veamos el siguiente código.</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>Actualizar el estado de React no hace que <code>filing</code> deje de ser una instancia de <code>TaxFilingModel</code>. Sin embargo, si la persistencia de Redux/Zustand lo guarda y restaura como JSON, el valor recuperado puede ser un plain object sin métodos, y una llamada desprevenida a <code>filing.canAmend()</code> puede provocar un error en tiempo de ejecución. Al pasarlo de React Server Component a Client Component, el fallo ocurre antes, porque una instancia de Class no es un valor de props serializable compatible.</p>
<h4 id="dificultad-para-garantizar-la-inmutabilidad"><a class="anchor" href="#dificultad-para-garantizar-la-inmutabilidad">Dificultad para garantizar la inmutabilidad</a></h4>
<p>React detecta los cambios de estado mediante <strong>comparación de referencias (referential equality)</strong>. Si un método de una instancia de Class modifica internamente el estado con algo como <code>this.items.push(...)</code>, la referencia no cambia y React no activa un nuevo renderizado. Así que <code>addDeduction(item)</code> tendría que devolver siempre una instancia nueva, por ejemplo con <code>return new DeductionList([...this.items, item])</code>; pero entonces se diluye la ventaja de Class de «modificar un estado encapsulado». El resultado no es muy distinto de una actualización funcional.</p>
<h3 id="estrategias-para-lograr-cohesión-con-el-estilo-funcional"><a class="anchor" href="#estrategias-para-lograr-cohesión-con-el-estilo-funcional">Estrategias para lograr cohesión con el estilo funcional</a></h3>
<p>Entonces, ¿cómo podemos mejorar en el estilo funcional el problema de cohesión débil que vemos en <code>eat('jihoon', '감자탕')</code>? Estas son tres estrategias que me han resultado eficaces.</p>
<h4 id="cohesionar-mediante-un-namespace-de-módulo"><a class="anchor" href="#cohesionar-mediante-un-namespace-de-módulo">Cohesionar mediante un namespace de módulo</a></h4>
<p>Es la opción más intuitiva. Se convierte el propio archivo —el módulo— en una unidad de dominio y se usa un namespace al importarlo. Podemos reutilizar tal cual el archivo <code>domain/filing.ts</code> definido antes.</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>Aunque <code>FilingModel.canAmend(filing)</code> no llega a ser tan directo como <code>filing.canAmend()</code>, el código deja claro al menos que la función pertenece al dominio Filing. También desaparece el riesgo de mezclar funciones de varios dominios.</p>
<h4 id="el-sujeto-del-dominio-como-primer-argumento"><a class="anchor" href="#el-sujeto-del-dominio-como-primer-argumento">El sujeto del dominio como primer argumento</a></h4>
<p>Existe otra convención para expresar cohesión en el estilo funcional: <strong>colocar siempre como primer argumento al «sujeto de la acción»</strong>. Si unificamos las firmas como <code>canAmend(filing)</code> y <code>calculateTotalIncome(income)</code>, <code>canAmend(filing)</code> se lee como «consultar canAmend sobre filing». Es coherente con la forma de pensar de las pipelines de Unix (<code>data |> transform</code>). De hecho, el receptor de métodos de Go sigue exactamente este patrón, al igual que el bloque <code>impl</code> de Rust cuando recibe <code>self</code> como primer argumento.</p>
<h4 id="agrupar-el-comportamiento-en-una-factory"><a class="anchor" href="#agrupar-el-comportamiento-en-una-factory">Agrupar el comportamiento en una Factory</a></h4>
<p>Este patrón resulta útil cuando echamos de menos la cohesión de Class. Una función factory devuelve a la vez el objeto de dominio y su comportamiento.</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>Este patrón combina la expresividad de Class (<code>filing.canAmend()</code>) con la practicidad de componer comportamiento mediante un objeto literal. Como el objeto devuelto contiene propiedades que son funciones, no es en sí mismo un dato serializable como JSON. También tiene el coste de crear cada vez nuevos objetos de función, pero rara vez supone un problema de rendimiento con el volumen de datos habitual en frontend.</p>
<h2 id="hasta-dónde-debemos-separar"><a class="anchor" href="#hasta-dónde-debemos-separar">¿Hasta dónde debemos separar?</a></h2>
<p>Al leer sobre Clean Architecture encontramos estructuras ideales con tres o cuatro capas y definiciones de Port/Adapter. Sin embargo, aplicar esta estructura a todos los proyectos puede convertirse en sobreingeniería (over-engineering).</p>
<p>Estos son, en mi opinión, unos criterios prácticos.</p>
<ul>
<li><strong>Separar los tipos de dominio de los tipos de respuesta de la API</strong>. Ya sea con <code>interface</code> o con <code>type</code>, se definen en un archivo aparte los conceptos de dominio que usa el frontend.</li>
<li><strong>Extraer de los componentes la lógica que contenga reglas de negocio</strong>. No es imprescindible que esté en una carpeta <code>domain/</code>. Lo importante es convertirla en funciones puras que no dependan de React.</li>
<li><strong>Transformar la respuesta de la API en el modelo de dominio en un único lugar</strong>. Ya sea mediante una función Mapper o un esquema de Zod, se crea una estructura en la que basta modificar ese punto para evitar que el cambio se propague.</li>
</ul>
<p>Si el proyecto gana complejidad, también pueden plantearse estas opciones.</p>
<ul>
<li><strong>Dividir las carpetas por Bounded Context</strong>. El <a href="https://frontend-fundamentals.com/" target="_blank" rel="noopener noreferrer">capítulo de frontend de Toss</a> también recalca el principio de «colocar en el mismo directorio los archivos que cambian juntos». Al dividir las carpetas por dominios, las rutas de importación revelan de forma natural sus límites.</li>
<li><strong>Introducir una capa de Use Case</strong>. Cuando la combinación de lógica de dominio se vuelve compleja, hace falta una capa Application que reúna en una sola función un escenario como «consultar información de ingresos → aplicar el coeficiente de gastos → calcular las deducciones → calcular la cuota → determinar la devolución».</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>Incluso dentro de un único dominio fiscal, el <strong>cálculo de la cuota (tax)</strong>, la <strong>gestión de declaraciones (filing)</strong> y las <strong>deducciones (deduction)</strong> se dividen en subdominios independientes. Aunque cambien los tipos impositivos, no se ven afectadas las reglas de transición de estado de las declaraciones; aunque se añadan deducciones, el flujo para presentar una declaración permanece intacto. Esta es una aplicación práctica de Bounded Context.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En resumen, el frontend tiene su propia lógica de dominio, y sus reglas no van en los componentes sino en funciones puras junto a los tipos. Las respuestas de la API se convierten en tipos de dominio en un único mapper, la presentación se separa en un ViewModel y lo que cambia junto se mantiene junto.</p>
<p>Llevar esto a la práctica en frontend no consiste simplemente en dividir carpetas, sino en <strong>evaluar conscientemente varias capas de límites</strong>. «¿Esto es una regla de negocio o lógica de presentación?», «¿estos datos son estado de dominio o estado de la UI?», «¿esta función tiene suficiente cohesión?». El mero hábito de formularse estas preguntas mejora de forma natural la estructura del código.</p>
<p>Por supuesto, no todos los proyectos necesitan todas las capas de Clean Architecture. Dividir una aplicación CRUD sencilla en cuatro capas y aplicar el patrón Factory a todos los dominios sería matar moscas a cañonazos. Entre la elegante cohesión de Class y la flexibilidad práctica del estilo funcional, la respuesta depende de la complejidad del proyecto y del contexto del equipo.</p>
<p>No hay una única respuesta correcta. Pero existe una diferencia clara entre <strong>«escribir código sin saber qué es el dominio»</strong> y <strong>«reconocer el dominio, evaluar sus límites y separarlo conscientemente»</strong>. Espero que quienes lean este artículo se pregunten al menos una vez en sus propios proyectos: «¿cuál es aquí el dominio y dónde debería estar este código?».</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">토스, E2E 자동화 여정</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>arquitectura</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[Términos de dominio]]></title>
            <link>https://hooninedev.com/es/260413</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260413</guid>
            <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta entrada quiero hablar de en qué se diferencian el dominio, el modelo de dominio, el objeto de dominio y el modelo de objetos de dominio. Está pensada para desarrolladores frontend que, leyendo...]]></description>
            <content:encoded><![CDATA[<p>En esta entrada quiero hablar de <strong>en qué se diferencian el dominio, el modelo de dominio, el objeto de dominio y el modelo de objetos de dominio</strong>.</p>
<p>Está pensada para desarrolladores frontend que, leyendo sobre DDD, se han preguntado si estas palabras señalan lo mismo. Al terminar, podrás colocar los cuatro términos en una sola línea que baja de lo abstracto a lo concreto y explicar por qué el tipo de una respuesta de la API no es un modelo de dominio.</p>
<p>Me he topado con estas palabras muy a menudo como desarrollador, pero cuando alguien pregunta «¿qué es exactamente un dominio?», no es fácil responder con claridad. (Siendo sincero, cuando empecé a programar creía que dominio se refería a lo de «www».) Todos los ejemplos usan el cálculo del impuesto sobre la renta.</p>
<h2 id="dominio-domain"><a class="anchor" href="#dominio-domain">Dominio (Domain)</a></h2>
<p>Empecemos por la pregunta más básica. ¿Qué es un <strong>dominio</strong>?</p>
<p>Eric Evans lo define así en su libro <strong>Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)</strong>.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Una esfera de conocimiento, influencia o actividad.</p></div><div class="quote-original" lang="en"><p>"A sphere of knowledge, influence, or activity."</p></div></blockquote>
<p>Dicho de forma sencilla, el dominio es la propia <strong>área problemática que se quiere resolver mediante programación</strong>. Si creamos un servicio para presentar declaraciones de impuestos, el dominio es la «declaración de impuestos»; si creamos una plataforma de reclamaciones de seguros, el dominio es la «reclamación de seguros». El dominio no es código. Es un área problemática del mundo real que existe antes que el software.</p>
<p>¿Qué significa esto para quien desarrolla frontend? La UI que construimos es, al fin y al cabo, una <strong>ventana (window)</strong> que permite mostrar este dominio al usuario y manipularlo. Si desarrollamos un servicio de devolución de impuestos como Toss Income o Samjjeomsam, cuyo dominio principal son los impuestos, representamos en la UI conceptos de dominio como los tipos de ingresos, el coeficiente de gastos, las deducciones sobre la renta, los créditos fiscales y el importe de la devolución. Por eso, quien desarrolla frontend también debe comprender a fondo el dominio con el que trabaja. Tan importante como crear buenos componentes de UI es saber <strong>«qué problema resuelve este servicio»</strong>.</p>
<p>Pero incluso dentro de un único dominio como el de los «impuestos» existen numerosos subdominios. Basta con observar el flujo de cálculo del impuesto sobre la renta global que conozco a grandes rasgos.</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="Pipeline de cálculo del impuesto sobre la renta, de los ingresos brutos al pago o la devolución, con cada paso coloreado según los subdominios de ingreso, deducción, impuesto y declaración" width="1090" height="566" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>Cada etapa de este flujo constituye un subdominio con reglas y datos propios. Dentro del gran dominio de los «impuestos» se entrelazan subdominios como ingresos (Income), deducciones (Deduction), cuota tributaria (Tax) y declaración (Filing). Cómo dividirlos en el código es precisamente la cuestión central del modelado de dominio.</p>
<h2 id="modelo-de-dominio-domain-model"><a class="anchor" href="#modelo-de-dominio-domain-model">Modelo de dominio (Domain Model)</a></h2>
<p>Entonces, ¿qué es un modelo de dominio? ¿En qué se diferencia el dominio del «modelo de dominio»?</p>
<p><a href="https://martinfowler.com/eaaCatalog/domainModel.html" target="_blank" rel="noopener noreferrer">Martin Fowler</a> define el modelo de dominio como un modelo de objetos del dominio que incorpora tanto comportamiento como datos. La definición de Eric Evans va un paso más allá.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Un sistema de abstracciones que describe determinados aspectos de un dominio y que puede utilizarse para resolver problemas relacionados con ese dominio. — 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>La clave está en la <strong>«abstracción selectiva»</strong>. Un modelo de dominio no contiene todo lo que existe en el mundo real. Del mismo modo que un director de cine no registra cada escena de la realidad, sino que elige solo las necesarias para contar una historia, el modelo de dominio <strong>selecciona y estructura únicamente los aspectos necesarios para resolver el problema</strong>.</p>
<p>Hay aquí un punto importante: un modelo de dominio no tiene por qué ser código. Puede ser un diagrama dibujado en una pizarra o incluso un modelo mental (Mental Model) compartido por el equipo. En definitiva, el propio término modelo de dominio puede referirse a un concepto independiente del software.</p>
<p>Hay una confusión especialmente frecuente entre quienes desarrollan frontend: ver la estructura de una respuesta de API y pensar «este es el modelo de dominio». Sin embargo, eso es un <strong>modelo de datos (Data Model)</strong>, no un modelo de dominio.</p>
<p>Podemos distinguirlos así.</p>
<table>
<thead>
<tr>
<th>Categoría</th>
<th>Modelo de dominio</th>
<th>Modelo de datos</th>
</tr>
</thead>
<tbody>
<tr>
<td>Propósito</td>
<td>Expresar conceptos y reglas de negocio</td>
<td>Definir estructuras de almacenamiento o transmisión</td>
</tr>
<tr>
<td>Lenguaje</td>
<td>Términos de negocio (base imponible, crédito fiscal, devolución)</td>
<td>Términos técnicos (string, number, array)</td>
</tr>
<tr>
<td>Elementos incluidos</td>
<td>Datos + comportamiento (reglas)</td>
<td>Solo estructura de datos</td>
</tr>
<tr>
<td>Ejemplo</td>
<td>«A una base imponible de hasta 14 millones de wones se le aplica un 6 %»</td>
<td><code>{ taxableBase: number, taxRate: number }</code></td>
</tr>
</tbody>
</table>
<p>El modelo de datos define «qué forma tienen los datos que se intercambian», mientras que <strong>el modelo de dominio define «qué significan esos datos para el negocio y qué reglas siguen»</strong>. Si no se distingue entre ambos, los componentes pasan a depender directamente de la estructura de las respuestas de la API y cualquier cambio en el esquema del backend acaba afectando a todo el frontend.</p>
<h2 id="objeto-de-dominio-domain-object"><a class="anchor" href="#objeto-de-dominio-domain-object">Objeto de dominio (Domain Object)</a></h2>
<p>Si el modelo de dominio es un sistema de conceptos, el <strong>objeto de dominio</strong> es la entidad concreta que implementa uno de esos conceptos en el código.</p>
<p>En <a href="https://www.codewithjason.com/difference-domains-domain-models-object-models-domain-objects/" target="_blank" rel="noopener noreferrer">un artículo de Jason Swett</a>, creador de Code with Jason, se define así el objeto de dominio.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Llamaría objeto de dominio a cualquier objeto de mi modelo de objetos que también exista como concepto en mi modelo de dominio.</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>Es decir, si en el modelo de dominio existe el concepto de «renta global» y en el código hay un tipo llamado <code>Income</code>, ese <code>Income</code> es un objeto de dominio. Pero no todos los objetos del código son objetos de dominio. Elementos como <code>HttpClient</code>, <code>LocalStorageAdapter</code> o <code>useDebounce</code> son herramientas técnicas, no conceptos de dominio.</p>
<h3 id="entity-y-value-object"><a class="anchor" href="#entity-y-value-object">Entity y Value Object</a></h3>
<p>Evans clasifica los objetos de dominio en tres categorías: <strong>Entity</strong>, <strong>Value Object</strong> y <strong>Service</strong>. (Martin Fowler denomina esta clasificación «Evans Classification»). Un Service representa una operación de dominio que no pertenece de forma natural a un objeto concreto. Como el tema central de esta sección es cómo identificar los datos, nos centraremos en Entity y Value Object.</p>
<p>Una <strong>Entity</strong> es un objeto con una identidad única que persiste a lo largo del tiempo y de sus distintas representaciones. Una declaración de impuestos (TaxFiling), un contribuyente (Taxpayer) o un registro de ingresos (IncomeRecord) se identifican mediante un ID único; aunque cambien sus atributos, si conservan el mismo ID siguen siendo la misma Entity. Aunque se modifiquen las deducciones de una declaración, mientras no cambie su ID, seguirá siendo la misma declaración.</p>
<p>Un <strong>Value Object</strong> es un objeto cuyo significado depende únicamente de la combinación de sus atributos y se considera igual a otro si todos sus valores coinciden. El dinero (Money), un tipo impositivo (TaxRate) o un tramo impositivo (TaxBracket) son objetos cuyo propio valor constituye su significado. Un «tipo impositivo del 6 %» es el mismo «tipo impositivo del 6 %» dondequiera que se utilice.</p>
<p>¿Por qué es importante esta distinción en frontend? Veámoslo con el siguiente ejemplo.</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 es una Entity porque toma el id como criterio de identidad. (Tener un campo id no define por sí solo una Entity; lo esencial es que «ese id permite decidir si dos elementos son iguales o distintos»). Money se identifica únicamente por la combinación de amount y currency, sin id, y se considera el mismo valor cuando coinciden todos sus atributos.</p>
<p>Las Entity se comparan por ID y los Value Object, por atributos. Si esta distinción está clara, la lógica para decidir «si estos datos son iguales o distintos» en la gestión del estado se ordena de forma natural. Al actualizar un elemento de una lista, una Entity se busca por ID y se reemplaza, mientras que un Value Object se sustituye de forma inmutable (immutable replace).</p>
<h2 id="modelo-de-objetos-de-dominio-domain-object-model"><a class="anchor" href="#modelo-de-objetos-de-dominio-domain-object-model">Modelo de objetos de dominio (Domain Object Model)</a></h2>
<p>Ya sabemos qué son un «modelo de dominio» y un «objeto de dominio», pero ¿qué es un <strong>modelo de objetos de dominio</strong>?</p>
<p>Al investigar el tema, descubrí que, sorprendentemente, no existe una definición consensuada. Buena parte de la bibliografía considera «modelo de dominio», «modelo de objetos de dominio», «modelo conceptual (conceptual model)» y «modelo de objetos de análisis (analysis object model)» como <strong>sinónimos en la práctica</strong>: distintos nombres para el modelo conceptual que se dibuja durante la fase de análisis orientado a objetos.</p>
<p>También hay quien los considera capas algo más separadas. Una explicación representativa de esta visión sostiene que <strong>el modelo de objetos es el punto en el que el modelo de dominio se transforma en código real</strong>.</p>
<p>Desde esta segunda perspectiva, el <strong>modelo de objetos</strong> es la estructura de <strong>todos los objetos de código</strong> del sistema. Incluye también herramientas técnicas como <code>HttpClient</code> y <code>useDebounce</code>. Dentro de él, el <strong>subconjunto de objetos que representan conceptos de dominio y las relaciones entre ellos</strong> constituye el <strong>modelo de objetos de dominio</strong>. Esta idea enlaza con la tradición del modelado orientado a objetos, que ha definido el «Object Model» como la estructura estática de un sistema —clases, atributos, operaciones y relaciones—.</p>
<p>Considero que esta perspectiva resulta más práctica para quien desarrolla frontend, porque en el código que escribimos los objetos de dominio y los objetos técnicos siempre aparecen mezclados.</p>
<p>En definitiva, <strong>dominio → modelo de dominio → modelo de objetos de dominio → objeto de dominio</strong> es una jerarquía que va de lo abstracto a lo concreto. El dominio es el concepto más amplio y el objeto de dominio, el más concreto. Por eso, al escribir código frontend, la cuestión que realmente debemos resolver es <strong>cómo estructurar el modelo de objetos de dominio, es decir, los tipos que representan los conceptos de dominio y las relaciones entre ellos</strong>.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En resumen, el <strong>dominio</strong> es el área problemática que queremos resolver; el <strong>modelo de dominio</strong> es el sistema conceptual que abstrae de forma selectiva ese problema; el <strong>modelo de objetos de dominio</strong> es, desde el enfoque que adopté aquí, la implementación en código de ese sistema conceptual; y el <strong>objeto de dominio</strong> es cada objeto individual de esa implementación.</p>
<p>Cómo se traduce esta distinción en el código, es decir, dónde debe vivir fuera de los componentes la lógica de dominio como el cálculo de impuestos y hasta dónde separarla, lo trato en <a href="/260418" target="_blank" rel="noopener noreferrer">Modelo de dominio</a>.</p>
<p>Ojalá que, la próxima vez que mires el tipo de una respuesta de la API, te preguntes al menos una vez: «¿esto es un modelo de datos o un modelo de dominio?».</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>arquitectura</category>
            <category>DDD</category>
        </item>
        <item>
            <title><![CDATA[Reflexiones sobre la refactorización del segundo simulacro de Toss Frontend Fundamentals]]></title>
            <link>https://hooninedev.com/es/260328</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260328</guid>
            <pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de mi experiencia de refactorización al participar en el segundo simulacro de Toss Frontend Fundamentals. Como siempre me han interesado las revisiones de código y la re...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de mi experiencia de refactorización al participar en el segundo simulacro de Toss Frontend Fundamentals.</p>
<p>Como siempre me han interesado las revisiones de código y la refactorización, decidí afrontar este interesante ejercicio publicado por Toss bajo el formato de simulacro de Frontend Fundamentals. El ejercicio consistía en refactorizar una aplicación de reserva de salas de reuniones. También incluía tests, por lo que contaba con una red de seguridad para comprobar que ninguna funcionalidad se rompiera durante el proceso.</p>
<p>Al final, dediqué dos días a la refactorización y quiero resumir lo que aprendí durante el proceso.</p>
<h2 id="el-primer-encuentro-con-el-código"><a class="anchor" href="#el-primer-encuentro-con-el-código">El primer encuentro con el código</a></h2>
<p>Lo primero que hice al abrir el código fue <strong>leer las especificaciones de los tests</strong>. Los tests son la documentación que explica con mayor honestidad qué debe hacer una aplicación. Revisé <code>App.easy.spec.tsx</code> y <code>App.hard.spec.tsx</code> para comprender todos sus requisitos.</p>
<p>Después examiné el código real y encontré dos componentes monolíticos.</p>
<ul>
<li><code>ReservationStatusPage</code> era un componente de 385 líneas que reunía en un único archivo la selección de fecha, la visualización de la línea temporal, el tooltip con los detalles de la reserva, la lista de mis reservas y la función de cancelación.</li>
<li><code>RoomBookingPage</code> era un componente de unas 300 líneas en el que se entrelazaban los filtros, la lista de salas, la lógica de creación de reservas y la sincronización de los parámetros de la URL.</li>
</ul>
<p>Mientras leía el código, antes de decidir que «había que mejorarlo», me centré en <strong>clasificar sus características</strong>: qué partes contenían información de dominio, cuáles tenían carácter de utilidad y cuáles pertenecían exclusivamente a la capa de UI.</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>Esta clasificación hizo que empezara a resultar evidente por dónde debía comenzar. Añadí comentarios breves en cada área del código para anotar posibles mejoras. (Me recordó a cuando entré en mi empresa actual y tuve que migrar un proyecto basado en jquery).</p>
<p>Entonces, ¿por dónde debía empezar?</p>
<h2 id="definir-la-estrategia-de-refactorización"><a class="anchor" href="#definir-la-estrategia-de-refactorización">Definir la estrategia de refactorización</a></h2>
<p>Decidí llevar a cabo la refactorización en el siguiente orden.</p>
<ol>
<li><strong>Gestión del código de servidor</strong>: separar queries y mutations</li>
<li><strong>Separación de la lógica de dominio</strong>: modelos Equipment, Room y Reservation</li>
<li><strong>Declaración de tipos</strong>: organizar el sistema de tipos a partir de los modelos de dominio</li>
<li><strong>Separación de funciones de utilidad</strong>: formato de fechas, cálculos de la línea temporal, etc.</li>
<li><strong>Separación de la capa de UI</strong>: dividir los componentes en unidades manejables según sus responsabilidades</li>
<li><strong>Abstracción y separación de responsabilidades</strong>: gestión de errores/carga y de query keys</li>
</ol>
<p>Elegí este orden para avanzar <strong>desde el exterior hacia el interior siguiendo la dirección de las dependencias</strong>. Primero organizaría la infraestructura —código de servidor y utilidades—, después establecería los modelos de dominio y, por último, puliría la UI. Si separaba primero los componentes de UI, podía acabar trasladando entre varios componentes una lógica de dominio y un código de queries que todavía no estaban organizados.</p>
<p>Con la estrategia definida, era hora de ponerla en práctica paso a paso.</p>
<h2 id="empezar-por-el-código-de-servidor-y-las-utilidades"><a class="anchor" href="#empezar-por-el-código-de-servidor-y-las-utilidades">Empezar por el código de servidor y las utilidades</a></h2>
<h3 id="separar-la-utilidad-de-formato-de-fecha"><a class="anchor" href="#separar-la-utilidad-de-formato-de-fecha">Separar la utilidad de formato de fecha</a></h3>
<p>Lo primero que modifiqué fue la función <code>formatDate</code>, porque estaba definida inline por separado y de forma idéntica en ambas páginas.</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>Aunque era un cambio pequeño, tenía un significado importante como primer commit de la refactorización. Era una especie de <strong>calentamiento</strong>: empezar por la parte más independiente y con menos efectos secundarios para comprobar que los tests siguieran pasando.</p>
<h3 id="separar-los-hooks-de-react-query"><a class="anchor" href="#separar-los-hooks-de-react-query">Separar los hooks de React Query</a></h3>
<p>A continuación, extraje a archivos independientes las llamadas a <code>useQuery</code> y <code>useMutation</code> que estaban escritas directamente dentro de los componentes. Utilicé el patrón <code>queryOptions</code> para convertir la configuración de las queries en unidades reutilizables.</p>
<p>Durante este proceso también definí de forma explícita los tipos de respuesta de la API que se encontraban en <code>remotes.ts</code>. Los tipos que antes se propagaban como <code>any</code> pasaron a estar claramente definidos como <code>GetRoomsResponse</code>, <code>GetReservationsResponse</code>, etc.</p>
<p>Una vez organizada la capa de infraestructura, podía centrarme en los modelos de dominio.</p>
<h2 id="separar-los-modelos-de-dominio"><a class="anchor" href="#separar-los-modelos-de-dominio">Separar los modelos de dominio</a></h2>
<p>El punto de inflexión más importante de la refactorización fue <strong>separar los modelos de dominio en un directorio <code>models/</code> independiente</strong>.</p>
<p>En el código original, constantes de negocio como <code>EQUIPMENT_LABELS</code> y <code>TIME_SLOTS</code> estaban declaradas al principio de los archivos de componentes. Los tipos de <code>Room</code> y <code>Reservation</code> solo existían en el handler del servidor (<code>_tosslib/server/types.ts</code>) y, en el código del cliente, se utilizaban prácticamente como <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>¿Por qué es importante separar los modelos de dominio? Cuando la lógica de negocio depende de un componente de UI, para modificarla también hay que revisar la lógica de renderizado del componente. En cambio, si reside de forma independiente en el directorio <code>models/</code>, las reglas de negocio pueden modificarse por separado de la UI. Por supuesto, una separación perfecta es difícil de lograr en la práctica, pero lo esencial es crear, como mínimo, <strong>una estructura que permita predecir que «esta lógica estará aquí»</strong>.</p>
<p>Una vez separados los modelos de dominio, ¿hasta qué punto podía aligerarse la UI?</p>
<h2 id="descomponer-los-componentes"><a class="anchor" href="#descomponer-los-componentes">Descomponer los componentes</a></h2>
<h3 id="reservationstatuspage"><a class="anchor" href="#reservationstatuspage">ReservationStatusPage</a></h3>
<p>Este fue el commit que produjo el cambio más drástico y también el que más tiempo me llevó. Dividí el componente monolítico de 385 líneas de la siguiente manera.</p>
<pre><code>ReservationStatusPage/
├── index.tsx                    # 페이지 레벨
└── components/
    ├── DateSelector.tsx         # 날짜 선택 UI
    ├── ReservationTimeline.tsx  # 타임라인
    └── MyReservation.tsx        # 내 예약 목록 + 취소
</code></pre>
<p>El criterio para separar cada parte fue <strong>«¿tiene este código sentido de manera independiente?»</strong>. Visualizar la línea temporal es una responsabilidad independiente que recibe los datos de las reservas de una fecha y dibuja una cuadrícula. Consultar y cancelar las reservas del usuario también lo es. No había ningún motivo para que estuvieran en el mismo archivo.</p>
<p>Después de la separación, <code>index.tsx</code> quedó limitado al papel de <strong>orquestador (orchestrator)</strong>. Se encargaba de gestionar el estado, mostrar mensajes y componer los subcomponentes, mientras que delegaba en ellos el fetching de datos y los detalles de renderizado.</p>
<h3 id="roombookingpage"><a class="anchor" href="#roombookingpage">RoomBookingPage</a></h3>
<p>Dividí la página de reservas siguiendo el mismo principio.</p>
<pre><code>RoomBookingPage/
├── index.tsx                    # 페이지 레벨
├── components/
│   ├── BookingFilter.tsx        # 날짜, 시간, 인원, 장비, 층 UI
│   └── AvailableRoomList.tsx    # 예약 가능 방 목록
└── hooks/
    └── useBookingParams.ts      # URL searchParams 기반 상태 관리
</code></pre>
<p>Durante el proceso tomé una decisión interesante. Al principio intenté introducir <code>react-hook-form</code> + <code>zod</code> para validar el formulario. Sin embargo, finalmente los eliminé y los sustituí por el hook personalizado <code>useBookingParams</code>. Más adelante explicaré esta decisión con mayor detalle.</p>
<p>Llegados a este punto, surge una pregunta de forma natural: ¿hasta dónde debemos abstraer?</p>
<h2 id="el-nivel-adecuado-de-abstracción"><a class="anchor" href="#el-nivel-adecuado-de-abstracción">El nivel adecuado de abstracción</a></h2>
<p>Esta fue la parte sobre la que más reflexioné durante el simulacro.</p>
<h3 id="hasta-qué-punto-conviene-descomponer-las-condiciones-anidadas"><a class="anchor" href="#hasta-qué-punto-conviene-descomponer-las-condiciones-anidadas">¿Hasta qué punto conviene descomponer las condiciones anidadas?</a></h3>
<p>La lógica que determina si una sala se puede reservar combina varias condiciones: si la capacidad es suficiente, si cuenta con el equipamiento necesario, si está en la planta preferida y si existe un solapamiento horario. En el código original, todas ellas estaban escritas inline dentro de un único callback de <code>filter</code>.</p>
<p>Al extraer esta lógica a <code>models/roomFilter.ts</code>, separé cada condición en <strong>una función con nombre</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>La clave está en que <strong>solo separé en funciones aquello que podía recibir un nombre claro</strong>. Nombres como <code>isEnoughCapacity</code> y <code>hasRequiredEquipment</code> permiten predecir qué hace cada función sin mirar su implementación. Si el nombre tuviera que ser ambiguo, como <code>processRoomConditions</code>, la abstracción podría aumentar la carga cognitiva de quien lee el código.</p>
<p>Esto no significa, por supuesto, que sea la única respuesta correcta. Mi criterio fue <strong>«¿se puede predecir el comportamiento con solo leer el nombre de la función?»</strong>. Si es así, merece la pena abstraer; si no, dejarlo inline puede favorecer la legibilidad.</p>
<h3 id="searchparams-frente-al-estado-del-formulario"><a class="anchor" href="#searchparams-frente-al-estado-del-formulario">searchParams frente al estado del formulario</a></h3>
<p>También reflexioné bastante sobre dónde debía residir el estado de los filtros de reserva. En el código original, cada valor se gestionaba mediante <code>useState</code> y se sincronizaba con los searchParams de la URL mediante <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>Primero probé a introducir <code>react-hook-form</code> + <code>zod</code> para gestionarlos como un formulario. Sin embargo, finalmente los eliminé y los sustituí por el hook <code>useBookingParams</code>, que utiliza <strong>los searchParams como única fuente de verdad (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>El principal motivo de esta decisión fue considerar que <strong>«no conviene que estos estados evolucionen por separado»</strong>. Si tanto <code>useState</code> como <code>searchParams</code> mantienen su propio estado, pueden producirse discrepancias según el momento de la sincronización. En cambio, si solo se utilizan los searchParams como estado, la URL pasa a ser el estado de la aplicación y el problema de sincronización desaparece por completo. Además, si el usuario comparte la URL, se puede reproducir el mismo estado de los filtros.</p>
<p>Encontré reflexiones similares en los artículos de otros participantes: <strong>«unifiqué los searchParams de la URL como única fuente de verdad» y «opté por agrupar las props individuales de los filtros en un único objeto <code>filter</code>»</strong>. Aunque las soluciones se expresaban de forma distinta, partían del mismo diagnóstico: <strong>«los estados dispersos deben agruparse bajo un solo concepto»</strong>.</p>
<h2 id="estabilidad"><a class="anchor" href="#estabilidad">Estabilidad</a></h2>
<h3 id="suspense-y-errorboundary"><a class="anchor" href="#suspense-y-errorboundary">Suspense y ErrorBoundary</a></h3>
<p>Una vez definida la estructura de los componentes, añadí la gestión de errores y estados de carga. El orden es importante porque solo se puede decidir dónde establecer un Boundary después de definir el árbol de componentes.</p>
<p>Utilicé la librería <code>react-error-boundary</code> para envolver cada unidad independiente de fetching de datos con <code>ErrorBoundary</code> y <code>Suspense</code>. Aunque falle la línea temporal, la lista de mis reservas debe seguir mostrándose con normalidad, y viceversa.</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="gestión-centralizada-de-las-query-keys"><a class="anchor" href="#gestión-centralizada-de-las-query-keys">Gestión centralizada de las query keys</a></h3>
<p>Al separar los hooks de las queries durante la refactorización, surgió el problema de que las query keys quedaron dispersas en varios archivos. Esto dificultaba saber qué key debía utilizarse para la invalidation dentro del <code>onSuccess</code> de una mutation.</p>
<p>Introduje <code>@lukemorales/query-key-factory</code> para gestionar las query keys de forma centralizada.</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>Así se pueden utilizar con la forma <code>useSuspenseQueries({ queries: [roomKeys.list, reservationKeys.list(date)] })</code>, de modo que la query key y la función de fetching siempre se desplazan juntas. También extraje las rutas como constantes <code>PATHS</code> para eliminar strings hardcodeados.</p>
<h2 id="cuál-era-la-intención-de-quienes-diseñaron-el-ejercicio"><a class="anchor" href="#cuál-era-la-intención-de-quienes-diseñaron-el-ejercicio">¿Cuál era la intención de quienes diseñaron el ejercicio?</a></h2>
<p>Después de terminar la refactorización, tomé cierta distancia y reflexioné: ¿qué pretendía evaluar este simulacro?</p>
<p>Al leer los artículos de otros participantes, descubrí un punto en común interesante. En casi todos aparecía la frase <strong>«el código no se lee, se predice»</strong>. Nuestro cerebro no interpreta el código línea por línea, sino que lo lee anticipándose a partir de los patrones acumulados mediante la experiencia. Cuando esas predicciones fallan, la carga cognitiva aumenta drásticamente.</p>
<p>Desde esta perspectiva, el simulacro no evalúa simplemente la capacidad de programar, sino la capacidad de colaborar: <strong>«¿hasta qué punto puedes hacer que el código que leerán tus compañeros sea predecible?»</strong>. (Tal vez la auténtica habilidad de un ingeniero de software sea leer la mente tanto de quienes diseñan el ejercicio como de sus compañeros).</p>
<p>Al revisar las experiencias de otros participantes, me identifiqué con ideas como <strong>«no es fácil entender el código que ha escrito otra persona» y «es importante diseñar primero la interfaz, pero ese enfoque puede tambalearse ante una gran base de código existente»</strong>. Yo también viví algo parecido. Cuando el código existente ya funciona, surge la tentación de justificar su estructura: «Si ya funciona, ¿para qué cambiarlo?». Sin embargo, el objetivo central del simulacro era superar precisamente esa tentación y evaluar <strong>«con qué rapidez podría entender este código otra persona y si uno es capaz de analizar y resolver el problema con su propio criterio»</strong>.</p>
<h2 id="lo-que-aprendí-de-la-refactorización"><a class="anchor" href="#lo-que-aprendí-de-la-refactorización">Lo que aprendí de la refactorización</a></h2>
<p><strong>El orden de la refactorización determina el resultado.</strong> Avanzar desde el exterior —la infraestructura— hacia el interior —la UI— fue la ruta más segura para evitar enredos a mitad del proceso. Al dividir los componentes después de organizar las utilidades y los modelos de dominio, las dependencias de cada componente quedaron claras.</p>
<p><strong>El criterio para abstraer es el «nombre».</strong> Si al extraer algo a una función o variable su nombre puede explicar el comportamiento, merece la pena abstraerlo. Si el nombre será inevitablemente ambiguo, dejarlo inline puede ser una opción mejor.</p>
<p><strong>La ubicación del estado es la arquitectura.</strong> Los estados que deben evolucionar juntos tienen que residir en el mismo lugar. Desde un punto de vista estructural, es más sano utilizar únicamente los searchParams como fuente de verdad que sincronizar <code>useState</code> con <code>searchParams</code>.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>Después de terminar el ejercicio, conversé con dos compañeros. Al hablar y desarrollar mis ideas empezaron a surgir aspectos que no había visto mientras examinaba el código en solitario. En cuanto alguien pregunta «¿por qué lo hiciste así?» sobre una decisión estructural que yo había dado por sentada, aparecen lagunas de criterio de las que no era consciente.</p>
<p>Es cierto que la IA está reduciendo drásticamente el tiempo necesario para escribir y revisar código. Aun así, experiencias como esta son la razón por la que sigo considerando importantes las revisiones de código y las reuniones diarias. La IA puede comprobar la coherencia del código, pero señalar <strong>«esta es la perspectiva que has pasado por alto»</strong> sigue siendo responsabilidad de un compañero que comparte el mismo contexto. Descubrir lo que yo no había visto y estabilizar el producto gracias a ese descubrimiento: ¿no es esa la esencia de la colaboración?</p>
<p>No existe una única respuesta correcta al escribir código mientras resolvemos un problema. Otros participantes que hicieron el mismo simulacro eligieron caminos distintos, cada uno con sus propios motivos. Lo importante es <strong>poder explicar «por qué está escrito así»</strong>. Recomiendo a quienes lean este artículo que, al menos una vez, observen su código desde la perspectiva de alguien que lo ve por primera vez. Esa mirada puede convertirse en el criterio más poderoso para determinar la calidad del código.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
            <category>refactorización</category>
        </item>
        <item>
            <title><![CDATA[Ingeniero frontend de IA]]></title>
            <link>https://hooninedev.com/es/260302</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260302</guid>
            <pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta publicación quiero hablar, desde una perspectiva personal, de cómo pueden crecer y sobrevivir los ingenieros en la era de la IA. Uno de los textos que más me impresionó cuando era junior fue «...]]></description>
            <content:encoded><![CDATA[<p>En esta publicación quiero hablar, desde una perspectiva personal, de <strong>cómo pueden crecer y sobrevivir los ingenieros en la era de la IA</strong>.</p>
<p>Uno de los textos que más me impresionó cuando era junior fue <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">«Hoja de ruta profesional para ingenieros frontend: tres vías de especialización para perfiles junior», de Hwidong Bae</a>. El artículo organizaba la carrera de un ingeniero frontend en tres vías: <strong>especialización web (Software Engineer) / especialización en producto (Product Engineer) / especialización en operaciones (Full-Stack Engineer)</strong>, y abordaba además las «cinco competencias básicas de un ingeniero excelente» y los «tres puntos clave para convertirse en senior». En aquel entonces, la gran cuestión era decidir qué competencias desarrollar en cada vía. Sin embargo, ni siquiera habían pasado dos años desde que leí aquel texto cuando la propia cuestión cambió por completo.</p>
<p>Últimamente, cuando hablo con otros ingenieros, percibo que sus inquietudes tienen un tono algo distinto de las que venía oyendo durante los últimos años.</p>
<ul>
<li>«En la empresa hemos adoptado la IA y, si le damos un diseño, prácticamente lo construye todo. Es cómodo, pero...»</li>
<li>«El mercado laboral está realmente frío».</li>
<li>«Me da miedo hacer merge sin más del código generado por la IA, pero revisarlo línea por línea reduce la eficiencia. No sé qué hacer».</li>
</ul>
<p>Yo también pasé por una etapa parecida y todavía sigo en ella. Hace apenas uno o dos años veía la IA como «una buena herramienta de apoyo»; hoy, en cambio, hemos llegado a un entorno en el que cuesta imaginar el desarrollo sin IA (yo mismo le estoy pidiendo a Claude que investigue mientras escribo este artículo). Este texto pretende ser una especie de continuación del de Hwidong Bae: quiero ordenar, desde mi punto de vista, cómo ha cambiado el panorama desde entonces y qué competencias adicionales debemos desarrollar como ingenieros frontend en este nuevo escenario.</p>
<p>Una vez más, he intentado buscar y contrastar tantos datos como fuera posible, pero, dado que este campo cambia a una velocidad extraordinaria, pido de antemano comprensión si alguna parte ya ha quedado anticuada cuando se publique el artículo. Si hay algo que rebatir o debatir, no dudéis en dejarlo en los comentarios.</p>
<h2 id="pero-ahora-no-lo-hace-todo-la-ia"><a class="anchor" href="#pero-ahora-no-lo-hace-todo-la-ia">«¿Pero ahora no lo hace todo la IA?»</a></h2>
<p>Antes que nada, hay una cuestión que debemos aclarar. ¿Es cierta la frase «la IA lo hace todo»? ¿Hasta qué punto es verdad y a partir de dónde empieza la ilusión?</p>
<p>En febrero de 2025, <a href="https://x.com/karpathy/status/1886192184808149383" target="_blank" rel="noopener noreferrer">Andrej Karpathy</a>, cofundador de OpenAI y antiguo director de IA de Tesla, publicó esta frase en Twitter.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Hay un nuevo tipo de programación al que llamo «vibe coding»: consiste en dejarse llevar por completo por las vibraciones, abrazar el crecimiento exponencial y olvidar incluso que el código existe.</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>El <strong>vibe coding</strong> es, en pocas palabras, «una forma de programar en la que se le entrega el teclado a la IA y uno se limita a describir en lenguaje natural lo que quiere». No hay documentos de arquitectura, ni boilerplate, ni búsquedas de puntos y comas. El código simplemente avanza siguiendo el vibe. En menos de un año, el término se asentó como parte del vocabulario habitual de la comunidad de desarrolladores anglófona.</p>
<p>Sin embargo, exactamente un año después, en febrero de 2026, el mismo Karpathy <a href="https://thenewstack.io/vibe-coding-is-passe/" target="_blank" rel="noopener noreferrer">dio un paso atrás</a>. Propuso sustituir la expresión vibe coding por <strong>«agentic engineering»</strong>. La diferencia entre ambos conceptos es clara.</p>
<ul>
<li><strong>Vibe coding</strong>: describir lo que se quiere y aceptar el resultado.</li>
<li><strong>Agentic engineering</strong>: diseñar el sistema, especificar las restricciones y utilizar la IA para acelerar una implementación cuyo razonamiento ya se ha completado mentalmente.</li>
</ul>
<p>Si hace un año el punto de partida era «basta con pedírselo y lo construye todo», ahora la propia «capacidad de diseñar qué pedirle a la IA y cómo hacerlo» se ha convertido en una competencia de ingeniería. Y esta corriente no se limita al tuit de una sola persona. Por esas mismas fechas, el ingeniero de Google <a href="https://addyosmani.com/" target="_blank" rel="noopener noreferrer">Addy Osmani</a> publicó el libro <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>, donde sentenció: «La IA no es más que un asistente, no un programador autónomo en el que se pueda confiar. Tú eres el desarrollador senior y el LLM existe para acelerar tu criterio».</p>
<h3 id="las-herramientas-avanzan-sin-freno"><a class="anchor" href="#las-herramientas-avanzan-sin-freno">Las herramientas avanzan sin freno</a></h3>
<p>El ecosistema de herramientas también evoluciona rápidamente en consonancia con esta corriente. A marzo de 2026, las herramientas de programación más mencionadas son Cursor, Claude Code, GitHub Copilot, Windsurf, v0 by Vercel, Bolt.new y Devin.</p>
<p>La evolución de v0 es especialmente simbólica. Vercel utiliza la expresión <a href="https://venturebeat.com/infrastructure/vercel-rebuilt-v0-to-tackle-the-90-problem-connecting-ai-generated-code-to" target="_blank" rel="noopener noreferrer">«90% problem»</a>, que significa que el 90% del desarrollo real tiene lugar dentro de una base de código y una infraestructura ya existentes. Al principio bastaba con que v0 creara buenos prototipos greenfield; ahora importa repositorios de GitHub para trabajar directamente con ellos, aplica sistemas de diseño y obtiene automáticamente las variables de entorno de despliegue. En cierto modo, el ecosistema de herramientas está respondiendo directamente a la objeción de los perfiles senior: «¿La IA no sirve únicamente para crear demos de juguete?».</p>
<p>Las bases de código de las grandes tecnológicas son el mejor escaparate de este cambio.</p>
<p>Sundar Pichai, de Google, <a href="https://fortune.com/2024/10/30/googles-code-ai-sundar-pichai/" target="_blank" rel="noopener noreferrer">anunció en la presentación de resultados del tercer trimestre de octubre de 2024 que «más del 25% del código nuevo había sido generado por IA y después revisado y aprobado por ingenieros»</a>, y en abril de 2025 afirmó que la cifra había superado el 30%. Satya Nadella, de Microsoft, <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">reveló en LlamaCon, en abril de 2025, que «hasta el 30% de nuestro código está escrito por IA»</a>. En Meta, el objetivo interno ha llegado al nivel de que «para la primera mitad de 2026, el 65% de los ingenieros genere con IA más del 75% de sus commits».</p>
<p>En Corea la tendencia no es distinta. <a href="https://toss.tech/article/toss-frontend-ai-docs" target="_blank" rel="noopener noreferrer">Toss</a> construyó un sistema de documentación basado en IA para mejorar la DX y evitar que los desarrolladores tuvieran que buscar documentos, y fue aún más lejos al tratar temas como <a href="https://toss.tech/article/removing_designers_in_ai_era" target="_blank" rel="noopener noreferrer">«Qué ocurrió al eliminar a los diseñadores en la era de la IA»</a>. Daangn comparte experimentos de cada equipo todos los martes mediante <a href="https://medium.com/daangn" target="_blank" rel="noopener noreferrer">AI Show &#x26; Tell</a> y empezó a utilizar el <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">eslogan de contratación</a> «De ingeniero a builder». Woowa Brothers, por su parte, transmite mediante artículos como <a href="https://techblog.woowahan.com/22828/" target="_blank" rel="noopener noreferrer">«En una era en la que la IA escribe código, ¿aun así queréis ser desarrolladores?»</a> el mensaje de que «la esencia de un desarrollador no está en el código, sino en la capacidad de definir y resolver problemas».</p>
<h3 id="sin-embargo-las-cifras-cuentan-una-historia-algo-distinta"><a class="anchor" href="#sin-embargo-las-cifras-cuentan-una-historia-algo-distinta">Sin embargo, las cifras cuentan una historia algo distinta</a></h3>
<p>Si nos quedáramos solo con lo anterior, sería fácil concluir que «ahora basta con pedírselo y todo se resuelve». Pero los datos reales cuentan una historia algo distinta.</p>
<p>Veamos primero las cifras de la <a href="https://survey.stackoverflow.co/2025/ai" target="_blank" rel="noopener noreferrer"><strong>2025 Stack Overflow Developer Survey</strong></a>, que analiza de forma integral el estado del desarrollo de software.</p>
<ul>
<li>El 84% de los desarrolladores afirmó utilizar herramientas de IA o tener previsto hacerlo. (Un aumento respecto al 76% de 2024).</li>
<li>El 51% de los desarrolladores profesionales usa herramientas de IA a diario.</li>
<li>Sin embargo, <strong>la opinión favorable hacia las herramientas de IA (positive sentiment) disminuyó</strong>. Tras superar el 70% en 2023 y 2024, cayó hasta el 60% en 2025.</li>
<li>Los desarrolladores senior con más de diez años de experiencia son quienes menos confían en los resultados de la IA.</li>
</ul>
<p>En resumen: <strong>«Todo el mundo las usa, pero cada vez se fía menos»</strong>.</p>
<p>Un experimento realizado en 2025 por el instituto de investigación sin ánimo de lucro <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> muestra de forma aún más llamativa la brecha entre esta percepción y la realidad. Fue un experimento controlado en el que se asignó aleatoriamente el uso o no de IA a 16 desarrolladores expertos de código abierto, con una media de cinco años de experiencia y 1.500 commits, para que completaran 246 tareas. Los resultados fueron los siguientes.</p>
<ul>
<li>Antes de empezar, los desarrolladores predijeron que «con IA serían un 24% más rápidos».</li>
<li>Justo después de terminar las tareas, seguían valorando que «parecía que habían sido alrededor de un 20% más rápidos».</li>
<li>Sin embargo, la medición real mostró que habían sido <strong>un 19% más lentos</strong>.</li>
</ul>
<p>Las causas señaladas por los investigadores son interesantes. La tasa de aceptación del código generado por la IA fue inferior al 44%; incluso el código rechazado exigió tiempo de revisión y pruebas, y hasta el código aceptado requirió bastante tiempo de revisión y corrección. Esa ilusión de haber ido más rápido pese a haber tardado más es una de las razones por las que los desarrolladores senior se muestran cada vez más escépticos ante la IA.</p>
<p>Además, la propia «calidad del código escrito por IA» tampoco es impecable. Veamos el experimento de <a href="https://www.veracode.com/blog/genai-code-security-report/" target="_blank" rel="noopener noreferrer"><strong>Veracode</strong></a>, que pidió a más de cien modelos de IA que escribieran código.</p>
<ul>
<li>El <strong>45% del código generado por IA contenía vulnerabilidades de seguridad del OWASP Top 10</strong>.</li>
<li>La <strong>tasa de fallos en la protección contra XSS (cross-site scripting) fue del 86%</strong>.</li>
<li>La tasa de fallos en la protección contra Log Injection fue del 88%.</li>
<li>Otro estudio informó de que la densidad de vulnerabilidades del código de IA era <strong>2,7 veces mayor</strong> que la del código humano.</li>
</ul>
<p>El 86% de fallos en XSS, algo directamente relacionado con el frontend, merece especial atención. La cifra muestra muy bien qué implica hacer merge sin más de un form input creado por la IA. (A quienes tengan experiencia con auditorías de seguridad frontend ya les resulta incómodo y preocupante escribir personalmente <code>dangerouslySetInnerHTML</code>; parece aún más aterrador cuando la IA lo introduce a escondidas).</p>
<p>Las señales sobre la calidad son parecidas. <a href="https://www.gitclear.com/ai_assistant_code_quality_2025_research" target="_blank" rel="noopener noreferrer"><strong>GitClear</strong></a> analizó 211 millones de líneas de cambios de código entre 2020 y 2024 y obtuvo estos resultados.</p>
<ul>
<li>Porcentaje de código revertido en las dos semanas posteriores a su escritura (Code Churn): 5,5% en 2020 → <strong>7,9%</strong> en 2024.</li>
<li>Porcentaje correspondiente a refactorización: 25% en 2021 → <strong>menos del 10%</strong> en 2024.</li>
<li>Porcentaje de copia y pega (clones): 8,3% en 2021 → <strong>12,3%</strong> en 2024 (en 2025 llegó a multiplicarse por cuatro).</li>
</ul>
<p>La interpretación no es muy difícil. Ha aumentado la capacidad de producir código rápidamente, pero ha disminuido la de escribir código que merezca la pena revisar y mejorar. Los <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">datos de Apiiro</a>, basados en el análisis de empresas Fortune 50, son aún más contundentes. Los desarrolladores asistidos por IA producen entre tres y cuatro veces más commits que sus compañeros, pero generan diez veces más security findings. Las rutas de escalada de privilegios (privilege escalation) aumentaron un 322% y los defectos de diseño arquitectónico, un 153%.</p>
<h2 id="qué-ha-sustituido-la-ia-y-qué-no-ha-podido-sustituir"><a class="anchor" href="#qué-ha-sustituido-la-ia-y-qué-no-ha-podido-sustituir">Qué ha sustituido la IA y qué no ha podido sustituir</a></h2>
<p>Las herramientas avanzan sin freno, pero las cifras presentan matices. Entonces, ¿qué ha sustituido exactamente la IA y qué no ha podido sustituir todavía? Solo si distinguimos claramente ambas cosas podremos saber dónde debemos invertir nuestro tiempo.</p>
<p>Lo que se ha sustituido es parte del trabajo de teclear directamente que hacían los desarrolladores. Hay menos situaciones en las que debamos escribir boilerplate o código repetitivo; con solo un diseño se puede obtener en pocos minutos una pantalla que respete las convenciones, y tanto el tiempo de búsqueda sobre sintaxis y API como la curva de aprendizaje se han reducido drásticamente. En suma, la IA ha <strong>igualado la «velocidad de producción»</strong>.</p>
<p>Pero todavía no ha sustituido el ámbito del «criterio». (Para ser exactos, sería más apropiado decir que «todavía no ha satisfecho las expectativas». Aunque existen diferencias entre personas en la capacidad de aprovechar la IA, aquí desarrollaré el argumento a partir de una experiencia de uso promedio).</p>
<p>El primer escollo es <strong>traducir los requisitos en especificaciones</strong>. Convertir necesidades de negocio ambiguas en casos límite precisos y máquinas de estados sigue requiriendo una intervención humana más profunda. Lo mismo ocurre con <strong>comprender el impacto en todo el sistema</strong>: aunque la IA ofrezca respuestas plausibles a preguntas como qué efecto tiene este componente sobre el bundle, si una dependencia permite tree shaking o cómo afecta un patrón de data fetching a la puntuación de <a href="https://web.dev/articles/vitals" target="_blank" rel="noopener noreferrer">Core Web Vitals</a> denominada <a href="https://web.dev/articles/inp" target="_blank" rel="noopener noreferrer">INP (Interaction to Next Paint)</a>, al final uno solo se queda tranquilo cuando una persona vuelve a revisarlas.</p>
<p>Tampoco pueden dejarse de lado <strong>la seguridad y la evaluación de riesgos</strong>, como demuestra el problema ya mencionado del 45% de vulnerabilidades OWASP; ni ámbitos como <strong>mantener el sistema de diseño y la coherencia</strong>, comprobando que un componente nuevo se ajuste a los tokens, las reglas de accesibilidad y los patrones de interacción existentes; o <strong>entender el contexto del cliente y del mercado</strong>, preguntándose por qué hace falta una función y en qué flujo de usuario debe integrarse.</p>
<p>Por último, tomando prestada una expresión del artículo de <a href="https://yceffort.kr/2026/02/frontend-engineering-in-ai-era" target="_blank" rel="noopener noreferrer">yceffort</a>, la <strong>gestión de la deuda cognitiva (Cognitive Debt)</strong> —«la brecha entre la complejidad del sistema y el grado en que el equipo lo comprende»— es precisamente un ámbito en el que la distancia crece aún más rápido desde la adopción de la IA. Por eso, la tarea de cerrar esa brecha sigue correspondiendo a las personas.</p>
<blockquote>
<p>Lo que desaparece no es el desarrollador, sino la forma que tenía su trabajo. El cuello de botella ha pasado de la «velocidad para construir» a la «velocidad para decidir».</p>
</blockquote>
<p>En la misma línea, el artículo de Toss <a href="https://toss.tech/article/will-ai-replace-developers" target="_blank" rel="noopener noreferrer">«¿Serán sustituidos los desarrolladores por la IA?»</a> ofrece un diagnóstico de mayor calado. Su idea principal es esta: la IA no reemplaza a toda la fuerza laboral, sino que está eliminando la escalera de aprendizaje (apprenticeship ladder). Dentro de diez o veinte años, cuando los senior actuales se jubilen, faltará la siguiente generación capaz de diseñar sistemas complejos. No es una cuestión del nivel de «qué haremos con la contratación del año que viene en nuestra empresa», sino una especie de bomba de relojería para todo el sector. (Creo que es un artículo realmente bien escrito para una época llena de incertidumbre).</p>
<p>La «primera versión que funciona» creada por la IA representa el 70%. El 30% necesario para llegar a una «versión que pueda ofrecerse a usuarios reales» pertenece a las personas. Y la capacidad de completar ese 30% no aparece de la noche a la mañana. Esa es la esencia del problema de la escalera de aprendizaje. Si desaparece el tiempo de «ensuciarse las manos» escribiendo boilerplate y componentes sencillos, también desaparecen quienes podrían completar ese 30%.</p>
<p>El artículo original de Hwidong Bae enumeraba como «cinco competencias básicas de un ingeniero excelente» <strong>escribir buen código, maximizar el valor presente (equilibrar un lanzamiento rápido y la mantenibilidad a largo plazo), tomar decisiones basadas en datos, ayudar eficazmente a los compañeros a decidir y aprender de forma constante</strong>. Las cinco siguen siendo válidas en la era de la IA, pero la última ocupa la posición más vulnerable. El aprendizaje no ha desaparecido; ha cambiado su objeto. Antes aprendíamos «cómo se usa esta herramienta»; ahora debemos dedicar tiempo a aprender «cómo funciona todo este sistema». Más inquietante aún es <a href="https://evan-moon.github.io/2026/04/18/developers-who-stopped-growing-in-ai-era/" target="_blank" rel="noopener noreferrer">el problema señalado por Evan Moon</a>: «en cuanto la IA se encarga de escribir el código, la carga cognitiva del cerebro cae drásticamente». Que disminuya la carga cognitiva suena bien, pero es peligroso porque esa carga era precisamente la materia prima del aprendizaje. <strong>Cuanto más cómodo resulta, menos se crece.</strong></p>
<p>Aquí surge de forma natural una pregunta. Entonces, ¿han dejado de tener sentido las tres vías del artículo de Hwidong Bae —especialización web, en producto y en operaciones—?</p>
<p>Yo no lo creo. Las vías siguen siendo válidas. Lo adecuado es considerar que cada una ha evolucionado un nivel para adaptarse a la era de la IA. Veamos cómo ha cambiado el panorama en cada caso.</p>
<h2 id="de-productor-a-verificador"><a class="anchor" href="#de-productor-a-verificador">De productor a «verificador»</a></h2>
<p>En el artículo original de Hwidong Bae, la vía de especialización web se agrupaba bajo el nombre de <strong>Software Engineer</strong>. Sus pilares eran «la comprensión profunda y el uso de Internet, los navegadores web y HTML/CSS/JS», el conocimiento de las ventajas e inconvenientes de las herramientas del ecosistema web, la experiencia resolviendo problemas y una actitud receptiva a las nuevas tecnologías. Como caminos hacia un puesto senior se proponían <strong>ingeniero en una empresa que desarrolla herramientas para el ecosistema web / formador de frontend / tech lead en una organización con productos complejos</strong>. En pocas palabras, eran «personas que profundizan en el funcionamiento del navegador y de HTML/CSS/JS», y hasta hace uno o dos años su mayor arma era «poder escribir código con más precisión que nadie».</p>
<p>¿Cómo ha cambiado su valor en la era de la IA? Si atendemos únicamente a la velocidad para escribir código, la IA ya los ha alcanzado. Sin embargo, <strong>«la capacidad de evaluar con precisión el código escrito por la IA»</strong> se ha convertido prácticamente en patrimonio exclusivo de estos perfiles.</p>
<ul>
<li>Persona no desarrolladora que utiliza IA: se ha implementado según mis requisitos y funciona correctamente.</li>
<li>Desarrollador que utiliza IA: funciona, pero esta dependencia puede causar ciertos problemas, y mejorar este patrón de esta forma encajaría mejor con las convenciones. Revisemos también las partes relacionadas.</li>
</ul>
<p>En el estudio de Veracode ya mencionado aparecían tasas de fallo del 86% en XSS y del 88% en Log Injection. Quienes pueden detectar y corregir esos problemas somos precisamente los especialistas como nosotros. Estos perfiles evolucionan de manera natural hacia funciones senior de control de calidad (QA) de lo producido por la IA.</p>
<p>Además, al ámbito de los especialistas se ha añadido un tema completamente nuevo: la <strong>UI generativa (Generative UI)</strong> y el <strong>diseño de interfaces de IA</strong>. Algunos ejemplos son las interfaces de chat que renderizan por streaming las respuestas de un LLM, los controles de abort para detenerlas a mitad de camino, el renderizado progresivo de Markdown y bloques de código, la UX que muestra inline los resultados de las llamadas a herramientas y la integración de asistentes mediante <a href="https://sdk.vercel.ai/" target="_blank" rel="noopener noreferrer">Vercel AI SDK</a> o <a href="https://modelcontextprotocol.io/" target="_blank" rel="noopener noreferrer">MCP (Model Context Protocol)</a>. En este campo, se está disparando la demanda de «personas que conozcan con precisión cómo funciona la web y, al mismo tiempo, comprendan las características operativas de los LLM y sepan aplicarlas y aprovecharlas».</p>
<h2 id="la-evolución-natural-hacia-product-engineer"><a class="anchor" href="#la-evolución-natural-hacia-product-engineer">La evolución natural hacia Product Engineer</a></h2>
<p>La vía de especialización en producto es la que más se ha beneficiado. Quienes comprenden bien el mercado y a los clientes y se comunican a menudo con las partes interesadas externas han obtenido, al incorporar la IA, un arma mucho más potente. Otra característica de esta vía era que, entre los caminos hacia puestos senior, también se proponía la expansión hacia otras profesiones, como <strong>ingeniero de growth o consultor / transición a PM, PO o CPO</strong>.</p>
<p>Un cambio interesante es que el nombre de esta vía ha empezado a convertirse en un estándar global. El artículo original ya la llamaba «Product Engineer», pero cuando lo leí la expresión me resultaba algo desconocida. Un año después, se ha asentado hasta el punto de que <a href="https://leerob.com/product-engineers" target="_blank" rel="noopener noreferrer">Vercel cambió en bloque «Fullstack Engineer» por «Product Engineer» en las descripciones de sus puestos</a>.</p>
<p>Lee Robinson señala tres cualidades esenciales de un Product Engineer.</p>
<ul>
<li><strong>Mentalidad de iteración (Iteration)</strong>: recorre rápidamente el ciclo despliegue → feedback → ajuste.</li>
<li><strong>Orientación al cliente</strong>: habla directamente con los clientes para mejorar el producto.</li>
<li><strong>Pragmatismo</strong>: «toda elección tecnológica no es más que un medio». Descarta sin dudar las herramientas que no contribuyan al objetivo del producto.</li>
</ul>
<p>Aquí hay una trampa: es peligroso que el ingeniero especializado en producto sea percibido únicamente como «alguien que construye rápido». Ahora que existe la IA, el riesgo es aún mayor. «Implementar funcionalidades rápidamente» es algo que cualquier otra profesión puede hacer ya con herramientas de IA. La diferencia de un Product Engineer está en «la capacidad de definir con precisión el problema del cliente y validarlo rápidamente con la solución más pequeña», no en «tener manos rápidas».</p>
<p>En esta corriente, la profesión de <strong>Design Engineer</strong> ha empezado a ascender a la categoría de puesto formal. Vercel está contratando <a href="https://cjroth.com/blog/2026-02-18-building-an-elite-engineering-culture" target="_blank" rel="noopener noreferrer">ingenieros de diseño en una vía profesional formal con salarios superiores a 200.000 dólares</a>, y Linear y Stripe avanzan en una dirección similar. Es una profesión que elimina el propio handoff entre frontend y diseño. Como la IA dibuja rápidamente, se ha vuelto más escasa la capacidad de abordar a la vez «qué dibujar y si el resultado encaja en un sistema de diseño coherente».</p>
<h2 id="orquestador-de-ia"><a class="anchor" href="#orquestador-de-ia">Orquestador de IA</a></h2>
<p>La vía de especialización en operaciones es la que está cambiando de forma más drástica. En el artículo original de Hwidong Bae se clasificaba como <strong>Full-Stack Engineer</strong> y se definía como «una persona muy interesada en la estructura, la integración, las pruebas y el despliegue del proyecto, que maneja directamente API e infraestructura sencillas, cubre los vacíos de la organización y mejora los procesos». En el último año o dos, se le ha añadido <strong>la función de operar los propios agentes de IA</strong>, por lo que el alcance de esta vía está ampliándose rápidamente.</p>
<p>Al resumir las <a href="https://beyond.addy.ie/2026-trends/" target="_blank" rel="noopener noreferrer">tendencias de 2026</a>, se destacó como concepto central la <strong>«orquestación de agentes de programación (Orchestrating Coding Agents)»</strong>. Significa ir más allá de encargarle algo a una sola IA para diseñar y operar un sistema en el que varios agentes de IA colaboran simultáneamente. En la misma línea, también se propone codificar directamente los flujos de trabajo profesionales, las puertas de calidad y las mejores prácticas del sector en la lógica operativa de los agentes mediante un framework llamado <a href="https://github.com/addyosmani/agent-skills" target="_blank" rel="noopener noreferrer">«agent-skills»</a>.</p>
<p>Tras reunir los materiales relacionados, estas son, a mi juicio, las nuevas palabras clave que deben manejar los ingenieros de la vía de operaciones.</p>
<ul>
<li><strong>MCP (Model Context Protocol)</strong>: estándar propuesto por Anthropic para conectar los LLM con herramientas externas.</li>
<li><strong>Gobernanza de IA</strong>: gestionar quién puede usar la IA y con qué contexto, y comprobar que no se filtren secretos.</li>
<li><strong>Evaluación de agentes (Evaluation)</strong>: pipeline que puntúa automáticamente los resultados producidos por un agente.</li>
<li><strong>Puerta de IA</strong>: verificación automática de seguridad y calidad antes de hacer merge de una PR, y etiquetado del código de IA.</li>
</ul>
<p>El artículo original proponía como caminos senior de la vía de operaciones puestos como <strong>ingeniero de equipo de plataforma en una organización a gran escala / tech lead / coach agile / technical program manager (TPM) / CTO</strong>. Esos caminos siguen siendo válidos, pero ahora se les han sumado puestos como <strong>«responsable de infraestructura de desarrollo con IA»</strong> e <strong>«ingeniero de productividad del desarrollador (DevProd)»</strong>.</p>
<p>Mientras cada una de las tres vías evoluciona por su cuenta, hay competencias que se han vuelto más importantes en todas ellas. En un principio quería pensar a cinco años vista, pero, con la velocidad actual del progreso, incluso un año parece una unidad demasiado grande. Por eso reduciré de momento el horizonte a «el año que viene» y señalaré las competencias que, en mi opinión, cobrarán más importancia.</p>
<h2 id="cinco-competencias"><a class="anchor" href="#cinco-competencias">Cinco competencias</a></h2>
<p><strong>La primera es la capacidad de redactar especificaciones (Specification).</strong> En la era de la IA, el «punto de partida de la programación» no es el teclado, sino la <strong>especificación</strong>. La capacidad de describir con precisión qué se le debe pedir a la IA se ha vuelto más importante que el propio código. Aquí, una especificación no es necesariamente un grandilocuente documento RFC. Puede tratarse de <strong>pruebas</strong> que expresan en código el comportamiento esperado de la lógica de negocio, de <strong>stories de Storybook</strong> que recogen los escenarios y el contrato visual de un componente de UI, o de <strong>definiciones de tipos</strong> que especifican el contrato del flujo de datos. En definitiva, consiste en establecer de antemano criterios que permitan verificar automáticamente lo producido por la IA; si se programa con IA sin ellos, los problemas se acumulan.</p>
<p><strong>La segunda es la capacidad de verificación y el criterio.</strong> La IA produce con seguridad código plausible pero incorrecto. Por eso considero esencial «la capacidad de revisar el código de IA con rapidez y precisión». Se trata de detectar si faltan headers de seguridad, sanitization de inputs o tokens CSRF; si siguen funcionando la accesibilidad —ARIA, navegación con teclado y focus traps—; y si existen problemas de rendimiento relacionados con el coste de renderizado, la memoria o el tamaño del bundle. Lanzar AI slop a una PR sin revisarlo es una dejación de funciones como ingeniero. Quien pulsa el botón de merge sigue siendo una persona y no puede trasladar esa responsabilidad a la IA. Es muy probable que, en la encuesta de Stack Overflow, la confianza de los senior en la IA sea la más baja precisamente porque tienen el ojo necesario para detectar estos detalles.</p>
<p><strong>La tercera es la comprensión de sistemas y el pensamiento arquitectónico.</strong> La IA maneja bien un archivo cada vez y posee una gran capacidad para reconocer flujos y relaciones. La IA corrige rápidamente los síntomas, pero un buen desarrollador encuentra la causa raíz. Una forma de cultivar esta competencia es realizar actividades deliberadas como una Architecture Retrospective. Como la velocidad de cambio del código ha aumentado, si no se eleva también conscientemente la comprensión del sistema por parte del equipo, la deuda cognitiva se acumula con rapidez.</p>
<p><strong>La cuarta es la capacidad de orquestar IA.</strong> El manejo de la propia IA también se está separando como un conjunto específico de competencias. Ya no se trata simplemente de «escribir buenos prompts», sino de abordar como un todo la capacidad de dividir el trabajo en tickets pequeños, elegir qué modelo utilizar para cada tarea, diseñar pipelines de evaluación y verificación de agentes, y definir estrategias de recuperación (rollback) cuando un agente falla. <a href="https://sourcegraph.com/blog/revenge-of-the-junior-developer" target="_blank" rel="noopener noreferrer">Steve Yegge</a> organiza esta evolución en <strong>seis waves (traditional → completions → chat → coding agents → agent clusters → agent fleets)</strong>.</p>
<p><strong>La quinta es Context Engineering.</strong> Es un concepto que <a href="https://www.faros.ai/blog/context-engineering-for-developers" target="_blank" rel="noopener noreferrer">Karpathy y Tobi Lütke, CEO de Shopify, empezaron a impulsar juntos a mediados de 2025</a> y que, en pocas palabras, consiste en «la capacidad de diseñar qué contexto mostrarle a la IA, en qué formato y en qué cantidad». En concreto, adopta formas como el trabajo con <strong>archivos CLAUDE.md / rules</strong>, donde se documentan las convenciones del proyecto, los principios arquitectónicos y las prohibiciones al alcance de la IA; la <strong>reducción deliberada del contexto</strong>, que selecciona únicamente los módulos relevantes en lugar de incorporar todos los archivos; la <strong>separación explícita de etapas</strong>, que divide planificación → implementación → verificación en sesiones distintas para evitar la contaminación del contexto; y el <strong>contexto externo mediante MCP</strong>, que conecta a través de interfaces estándar fuentes externas como sistemas de diseño, esquemas de API y datos de monitorización. <a href="https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents" target="_blank" rel="noopener noreferrer">La documentación oficial de Anthropic</a> lo denomina «the new prompt engineering» y afirma categóricamente que un único prompt jamás puede contener el conocimiento arquitectónico, los patrones y la tribal wisdom de un sistema. Dicho de otro modo, «diseñar de una vez un buen prompt» importa mucho menos que «diseñar un entorno en el que la IA reciba siempre un buen contexto».</p>
<p>Llegados hasta aquí, surge una pregunta natural. Entonces, ¿cómo se estudia todo esto en concreto? Los métodos que utilizo son, a grandes rasgos, cuatro.</p>
<h2 id="cómo-aprender"><a class="anchor" href="#cómo-aprender">Cómo aprender</a></h2>
<p>La «formación continua» señalada en el artículo original sigue siendo válida, pero debe cambiar <strong>la distribución del tiempo de aprendizaje</strong>.</p>
<p>Hay áreas a las que antes dedicábamos mucho tiempo y que ahora podemos reducir. En cambio, también existen ámbitos complejos que antes evitábamos por su dificultad o porque exigían mucho tiempo. Entre estos últimos están la redacción de especificaciones de pruebas, el uso de herramientas de medición del rendimiento (<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), la accesibilidad (<a href="https://www.w3.org/WAI/standards-guidelines/wcag/" target="_blank" rel="noopener noreferrer">WCAG</a>) y la seguridad, en especial el <a href="https://owasp.org/www-project-top-ten/" target="_blank" rel="noopener noreferrer">OWASP Top 10</a>. Y hay ámbitos completamente nuevos que debemos aprender, como Vercel AI SDK, LangChain.js, MCP, los patrones de UI por streaming y los pipelines de evaluación de agentes. <strong>Es importante reconocer qué competencias necesito y distribuir el tiempo en consecuencia.</strong></p>
<p>El código producido por la IA tiende a crecer mucho. Genera cientos de líneas por minuto. Por eso, si no se gestionan conscientemente el tamaño de las PR y el ciclo de merge, la propia revisión de código se viene abajo. Tras la adopción interna de la IA, el tamaño medio de las PR aumentó un 18%, los incidentes por PR un 24% y la tasa de fallos de cambios un 30%. Si relacionamos estas cifras con los datos anteriores, hacer cambios grandes y fusionarlos de una sola vez <strong>dificulta comprender el flujo y reflejar la intención, por lo que es importante dividir el trabajo en unidades más pequeñas.</strong></p>
<p>Hay una cuestión directamente relacionada con el problema de la reducción de la carga cognitiva señalado en el artículo de Evan Moon. Conviene reservar una o dos horas al día para escribir código sin IA. Por ejemplo, dibujar la arquitectura a mano o leer personalmente, línea por línea, código de un ámbito poco familiar. (Yo también intento programar sin IA todos los días durante ese sopor que sigue al almuerzo. Es un tiempo que reservo para no alejarme de aquella familiaridad).</p>
<p>No se trata simplemente de «no olvidar la forma antigua». La razón es que la propia profundidad no crece durante el tiempo en que la IA hace el trabajo por uno. Competencias como la verificación, el criterio y la comprensión de sistemas dependen del tiempo dedicado a enfrentarse directamente a los problemas.</p>
<h2 id="entonces-nosotros"><a class="anchor" href="#entonces-nosotros">Entonces, nosotros</a></h2>
<p>Aunque me he extendido mucho, lo cierto es que el perfil del ingeniero frontend que sobrevive en la era de la IA no difiere tanto de la conclusión del artículo original. Estos eran los tres puntos que allí se atribuían a un buen ingeniero senior.</p>
<ul>
<li>Se esfuerza por <strong>mantener unos fundamentos sólidos</strong>. (Mantiene y refuerza continuamente las cinco competencias básicas).</li>
<li>Aunque no sea un líder explícito, ejerce una influencia natural mediante una conducta ejemplar.</li>
<li>No se conforma con terminar bien el trabajo asignado, sino que examina el contexto anterior y posterior y genera un gran impacto.</li>
</ul>
<p>Aplicado a la era de la IA, quedaría así.</p>
<ul>
<li>Mantiene sólidos los <strong>fundamentos</strong> —web, sistemas y dominio— que hay más allá del código producido por la IA.</li>
<li>Marca personalmente la dirección, en lugar de dejarla en manos de la IA. Incluso cuando no es la persona responsable explícita, decide «hacia dónde hay que ir».</li>
<li>No utiliza la IA solo como herramienta de productividad personal, sino para eliminar los cuellos de botella del equipo y del sistema.</li>
</ul>
<p>Si examinamos los escritos de Andrej Karpathy, una autoridad de Open AI, el núcleo del <strong>«agentic engineering»</strong> que ahora subraya es, en última instancia, el mismo: diseñar el sistema, especificar las restricciones y usar la IA para acelerar una implementación cuyo razonamiento ya se ha completado mentalmente. Aunque cambien las herramientas, el control de la dirección sigue en manos humanas.</p>
<p>El mensaje final del artículo original también era que se convierte en senior «la persona que no se conforma con terminar bien el trabajo asignado, sino que examina el contexto anterior y posterior y genera un gran impacto». En la era de la IA solo ha cambiado la definición de ese «impacto». Hay quien hace merge de una pantalla creada por la IA en una hora con un «funciona, así que vale», y hay quien dedica treinta minutos más a comprobar hasta qué punto esa pantalla es razonable en términos de accesibilidad, seguridad, rendimiento y coherencia con el sistema. Dentro de un año, se reconocerá como senior al segundo. Sobre la frontera entre el 70% —funcionamiento— y el 30% —aplicación y aprovechamiento—, sobrevivirá quien se sitúe del lado del 30%.</p>
<p>Espero que los ingenieros frontend que lean este artículo también se lleven su propia respuesta a la pregunta «¿qué debo estudiar ahora?». Nadie conoce la respuesta correcta, pero estoy bastante convencido de que, cuanto más programe la IA, más sobrevivirán quienes sepan ver «lo que hay más allá del código». Concluyo con la esperanza de poder volver a escribir dentro de un año sobre cuánto habrá cambiado una vez más este panorama. <strong>(Si dentro de un año este artículo parece demasiado obvio o anticuado, quizá signifique que hemos sabido responder bien).</strong></p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>carrera</category>
            <category>IA</category>
        </item>
        <item>
            <title><![CDATA[Abstracción]]></title>
            <link>https://hooninedev.com/es/260201</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260201</guid>
            <pubDate>Sun, 01 Feb 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de la abstracción en programación y de cómo escribir buen código desde la perspectiva de la abstracción. Durante mi trabajo como desarrollador frontend me he preguntado ...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de la abstracción en programación y de cómo escribir buen código desde la perspectiva de la abstracción.</p>
<p>Durante mi trabajo como desarrollador frontend me he preguntado incontables veces: «¿Hasta qué punto debería separar esta lógica?» o «¿En qué unidades debería dividir este componente?». Al principio pensaba que abstraer consistía simplemente en extraer las partes comunes: convertir el código repetido en una función y reunir en una sola pieza los elementos compartidos por componentes similares. Sin embargo, después de ver varias veces cómo el código creado de ese modo se convertía con el tiempo en un monstruo cada vez más difícil de tocar, empecé a replantearme qué significa realmente abstraer.</p>
<p>En este artículo recopilo mis reflexiones sobre la esencia de la abstracción y sobre cómo utilizarla en el desarrollo frontend para crear buen código.</p>
<h2 id="lo-abstracto-y-la-abstracción"><a class="anchor" href="#lo-abstracto-y-la-abstracción">Lo abstracto y la abstracción</a></h2>
<p>Antes de entrar en materia, conviene aclarar qué significan exactamente en programación las palabras «abstracto» y «abstracción». Aunque parecen similares, su naturaleza es bastante distinta.</p>
<p><strong>Lo abstracto (Abstract)</strong> es un estado y una propiedad. Cuando decimos que algo «es abstracto», queremos decir que se han omitido los detalles concretos y que <strong>solo permanecen los conceptos esenciales</strong>. Este es precisamente el sentido de las clases o métodos marcados con la palabra clave <code>abstract</code> en Java o TypeScript: son planos incompletos en los que aún no se ha incorporado una implementación concreta y solo se ha definido la forma esencial.</p>
<p><strong>La abstracción (Abstraction)</strong> es un proceso y una acción. Consiste en simplificar un objeto complejo conservando únicamente sus características esenciales y eliminando los detalles innecesarios. Lo importante es que abstraer no significa «agrupar las cosas de cualquier manera», sino <strong>definir con precisión una responsabilidad en cada nivel</strong>.</p>
<p>En la vida cotidiana, «abstracto» suele utilizarse con el matiz de «ambiguo». En programación, sin embargo, la abstracción persigue justo lo contrario. Su objetivo no es generar ambigüedad, sino crear un nuevo nivel de significado que pueda ser absolutamente preciso. La esencia de la abstracción consiste en conservar la información relevante para un contexto dado y olvidar la que no lo es.</p>
<p>En definitiva, la diferencia puede resumirse así: <strong>lo abstracto es «el estado en el que solo queda lo esencial», mientras que la abstracción es «el proceso de dejar solo lo esencial»</strong>. Al diseñar código llevamos a cabo precisamente ese proceso: conservamos únicamente la interfaz esencial de una implementación compleja y ocultamos el resto.</p>
<p>Entonces, ¿por qué necesitamos esta abstracción en programación?</p>
<h2 id="por-qué-necesitamos-la-abstracción"><a class="anchor" href="#por-qué-necesitamos-la-abstracción">Por qué necesitamos la abstracción</a></h2>
<p>La razón fundamental por la que necesitamos abstracción en programación es sorprendentemente sencilla: <strong>para construir cosas más complejas</strong>. Cuando intentamos crear algo más complejo, resulta difícil recordar y manejar todos sus numerosos elementos. Por eso los agrupamos y los convertimos en conceptos abstractos más simples.</p>
<p>React, que los desarrolladores frontend utilizamos a diario, es un buen ejemplo. Para renderizar un solo componente tienen lugar internamente procesos complejos como la creación del Virtual DOM, la reconciliación (Reconciliation) y la manipulación del DOM real. Sin embargo, nosotros solo tenemos que escribir JSX sin preocuparnos por nada de eso, porque React ha abstraído todo ese proceso.</p>
<p>Veamos el siguiente código. Al utilizar el componente UserProfile podemos crear y manejar la UI sin conocer procesos internos complejos como la creación del VDOM o el 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>Antes era habitual que un desarrollador frontend configurase Webpack a mano, pero hoy frameworks como Next.js o Vite han abstraído la configuración del bundling. Gracias a ello podemos desarrollar aplicaciones sin conocer el funcionamiento interno del bundler y dedicar ese tiempo a <strong>problemas de mayor nivel, como la lógica de negocio o la experiencia de usuario</strong>. (Por eso considero que el concepto de abstracción es especialmente importante en el trabajo de un desarrollador frontend).</p>
<p>Ese es, en última instancia, el valor esencial de la abstracción: ocultar la complejidad para que algo parezca sencillo y permitir que cada persona se concentre únicamente en su ámbito. Gracias a ello podemos crear software cada vez más grande y complejo sin que una sola persona tenga que comprenderlo todo.</p>
<p>Pero si la abstracción es tan útil, ¿cuanta más haya, mejor? Pensemos con qué propósito deberíamos abstraer.</p>
<h2 id="reducir-el-contexto"><a class="anchor" href="#reducir-el-contexto">Reducir el contexto</a></h2>
<p>Muchos desarrolladores entienden la abstracción como «extraer las partes comunes». No es una definición incorrecta, pero describe solo una de las técnicas para abstraer, no la esencia de la abstracción.</p>
<p>Para mí, la esencia de la abstracción consiste en <strong>«reducir al nivel adecuado el contexto que necesita conocer quien lee el código»</strong>.</p>
<p>Veamos un ejemplo sencillo.</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>Quien lee este código debe entender la inicialización, la condición y el incremento del bucle; el acceso a cada elemento mediante un índice; la bifurcación tras comprobar una condición; y hasta cómo se actualiza la variable acumuladora externa. Sin embargo, lo que el código pretende hacer se resume en una sola frase: <strong>«calcular el total de los pedidos completados»</strong>. Para comprender esa frase hay que mantener a la vez cuatro contextos distintos en la cabeza.</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>Gracias a las abstracciones <code>filter</code> y <code>reduce</code>, el desarrollador solo necesita seguir dos intenciones: «seleccionar únicamente los pedidos completados» y «acumular los importes». El contexto de gestionar índices y declarar y actualizar una variable acumuladora ha desaparecido de la superficie del código.</p>
<p>Podemos ir un paso más allá.</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>Ahora quien lee el código ni siquiera necesita saber que el cálculo se realiza recorriendo un array. Solo queda la intención de negocio: «calcular el importe total de los pedidos completados». Podemos centrarnos no en <strong>cómo (How) se calcula</strong>, sino en <strong>qué (What) se calcula</strong>.</p>
<p>Desde esta perspectiva, también podemos ver que el código React que escribimos a diario es una combinación de innumerables abstracciones.</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>¿Qué ocurriría si todo el código interno de <code>emotion</code>, <code>date-fns</code> y <code>react</code> estuviera desplegado dentro de este archivo de componente? Sería difícil saber por dónde empezar a leer o distinguir qué parte corresponde a la lógica de negocio y cuál al código de una biblioteca. Como la abstracción oculta de manera adecuada el contexto de cada ámbito, podemos concentrarnos únicamente en la esencia: «mostrar la fecha de hoy».</p>
<p>Entonces, al diseñar código real, ¿en qué dirección deberíamos abordar la abstracción?</p>
<h2 id="qué-significa-que-el-nivel-de-abstracción-sea-alto-o-bajo"><a class="anchor" href="#qué-significa-que-el-nivel-de-abstracción-sea-alto-o-bajo">Qué significa que el nivel de abstracción sea alto o bajo</a></h2>
<p>Cuando hablamos de abstracción, hay un concepto imprescindible: el <strong>nivel de abstracción (Level of Abstraction)</strong>. ¿Qué significa exactamente que el nivel de abstracción de un código sea «alto» o «bajo»?</p>
<p>El <strong>código con un nivel de abstracción bajo</strong> se aproxima a los procedimientos concretos que ejecuta el ordenador: parsear directamente una cadena, recorrer un array mediante índices o manipular bytes. Expone sin disimulo <strong>cómo (How)</strong> funciona.</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>El <strong>código con un nivel de abstracción alto</strong> se expresa en el lenguaje del dominio de negocio o del espacio del problema. Algunos ejemplos son <code>processPayment(order)</code>, <code>sendNotification(user, message)</code> o <code>validateUserInput(formData)</code>. El código con un nivel de abstracción alto muestra <strong>qué (What)</strong> hace y oculta cómo lo hace.</p>
<p>En <strong>Clean Code</strong>, Robert C. Martin condensó esta idea en el principio <strong>«un solo nivel de abstracción por función (One Level of Abstraction per Function)»</strong>. Si dentro de una función se mezclan código de alto y de bajo nivel, quien la lee tiene que decidir en cada línea: «¿Esto forma parte de la lógica esencial o es un detalle de implementación?».</p>
<p>El problema se vuelve evidente al verlo en código real.</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>Quien lee esta función comienza siguiendo el contexto de alto nivel basado en reglas de negocio del «flujo de registro de usuarios», pero de repente se ve arrastrado a contextos de bajo nivel como la manipulación de buffers de hash, una consulta SQL y la cadena de una plantilla de correo electrónico. Después vuelve a saltar al alto nivel de <code>sendWelcomeEmail</code>. Cuando el nivel de abstracción sube y baja así, la mente de quien lee también tiene que subir y bajar con él.</p>
<p>Si reescribimos la misma función manteniendo un nivel de abstracción uniforme, el resultado es este.</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>Todas las instrucciones hablan desde el mismo nivel de abstracción. Cada función de nivel inferior se responsabiliza de cómo se implementa el envío del correo o de qué algoritmo se utiliza para hashear la contraseña. Quien lee esta función solo tiene que concentrarse en un único contexto: «el flujo completo del registro de usuarios».</p>
<p>Martin también denominó esto <strong>«la regla descendente (The Stepdown Rule)»</strong>. Al leer el código de arriba abajo, la visión general debería aparecer arriba y los detalles deberían revelarse conforme descendemos, como en un artículo periodístico.</p>
<p>Kent Beck presentó el mismo principio en <strong>Smalltalk Best Practice Patterns</strong> mediante el patrón <strong>Composed Method</strong>. Un método debe componerse únicamente de operaciones situadas en el mismo nivel de abstracción, y cada paso debe expresarse como una llamada a un método de una sola línea.</p>
<p>Al final, todas estas ideas convergen en una sola: <strong>una función debe hablar desde un único nivel de abstracción</strong>. Solo con respetar esta regla, la legibilidad del código mejora de forma notable.</p>
<p>Entonces, ¿cómo debemos orientar la abstracción? ¿Deberíamos partir de lo concreto o de lo abstracto?</p>
<h2 id="pensar-en-la-composición-de-piezas-no-en-extraer-elementos-comunes"><a class="anchor" href="#pensar-en-la-composición-de-piezas-no-en-extraer-elementos-comunes">Pensar en la composición de piezas, no en extraer elementos comunes</a></h2>
<p>En OOP se suele recomendar «extraer lo común de los elementos concretos para definir algo abstracto». Este enfoque no es incorrecto en sí mismo, pero creo que, si nos aferramos demasiado a él, corremos el riesgo de crear un diseño prisionero de los requisitos actuales.</p>
<p>Veamos un ejemplo. Supongamos que los requisitos incluyen tres botones, A, B y C, todos azules y redondeados, cuya única diferencia es el texto de la etiqueta. Si diseñamos extrayendo solo lo común, podríamos expresarlo así.</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>Los requisitos actuales se cumplen a la perfección. Pero unos días después, la persona responsable del producto dice:</p>
<blockquote>
<p>«Quiero poder cambiar el color del botón B».</p>
</blockquote>
<p>En ese momento, hasta el nombre <code>BlueRoundButton</code> empieza a resultar extraño. Podríamos añadir una prop para el color, pero el diseño era vulnerable a los cambios desde el principio porque partía de una coincidencia concreta: «botones azules y redondeados». (Y este es un caso leve; en la vida real llegan innumerables requisitos sobre la forma, el tamaño y muchos otros aspectos de los botones).</p>
<p>Cuando esta situación se repite, uno acaba entendiendo algo de manera natural: <strong>si extraemos elementos comunes de requisitos concretos, es fácil que incluso el resultado abstraído refleje únicamente los requisitos actuales</strong>.</p>
<p>Por eso prefiero el enfoque inverso. En lugar de extraer una abstracción de lo concreto, consiste en <strong>pensar primero en piezas abstractas y componerlas para crear algo concreto</strong>.</p>
<p>Imaginemos que estamos creando un componente de notificaciones toast. El requisito inicial es sencillo: «Cuando termine de guardarse, muestra un breve mensaje informativo en la parte inferior». Si lo abordamos extrayendo elementos comunes, obtendremos algo así.</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>El requisito actual queda cubierto a la perfección. Pero unos días después se pide «añadir un icono a la izquierda según se trate de un éxito o una advertencia». Se agregan <code>hasIcon</code> e <code>iconName</code>. Poco después llega otro requisito: «También necesitamos un toast con una barra de progreso de subida». Se suma otra prop, <code>progress</code>. Tras repetir varias veces este proceso, <code>Toast</code> termina siendo un componente con más de diez props y reglas ocultas que hay que memorizar sobre <strong>qué combinaciones son válidas y cuáles no</strong>. (Y, por lo general, esas reglas ni siquiera quedan documentadas en comentarios).</p>
<p>El diseño era vulnerable a los cambios porque desde el principio partía de <strong>una imagen concreta de cómo «debe ser un toast» en ese momento</strong>.</p>
<p>La historia cambia cuando abordamos el problema mediante la composición de piezas.</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>Se han separado la esencia inmutable —«un toast es un contenedor ligero para alojar algo»— y el aspecto concreto y propenso a cambiar —«qué contiene»—. Ahora podemos añadir tantas piezas nuevas como queramos o disponer las existentes en combinaciones nuevas sin tocar el interior de <code>Toast</code>. También desaparecen las reglas de validez entre combinaciones de props. Basta con <strong>introducir lo que queramos introducir</strong>.</p>
<p>Un desarrollador con experiencia podría decir: «¿No bastaría con diseñarlo desde el principio aplicando IoC (inversión de control)?». Es cierto. Pero podemos tomar esa decisión porque, después de innumerables tropiezos en el pasado, hemos desarrollado una intuición sobre «qué partes son más propensas a cambiar».</p>
<p>Si todavía no contamos con esa intuición, partir de la pregunta «¿De qué piezas se compone esta funcionalidad y cómo deberían ensamblarse?» facilita mucho la creación de un diseño abierto al cambio.</p>
<p>Llegados a este punto, surge de forma natural otra pregunta: ¿con qué criterio debemos dividir las piezas y cómo debemos presentarlas al exterior?</p>
<h2 id="tres-claves-para-una-buena-abstracción"><a class="anchor" href="#tres-claves-para-una-buena-abstracción">Tres claves para una buena abstracción</a></h2>
<h3 id="pensar-en-la-forma-de-expresarla"><a class="anchor" href="#pensar-en-la-forma-de-expresarla">Pensar en la forma de expresarla</a></h3>
<p>La virtud más importante de un módulo abstraído es que su comportamiento pueda deducirse sin abrir el código fuente. Kent Beck llamó a esto el patrón <strong>«nombre que revela la intención (Intention-Revealing Name)»</strong> y señaló que, si no podemos asignarle un nombre conciso, deberíamos reconsiderar la propia abstracción.</p>
<p>Disponemos principalmente de dos herramientas para conseguirlo: <strong>los nombres</strong> y <strong>los tipos</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>Con solo ver el nombre <code>calculateDiscountedPrice</code>, sabemos que recibe el precio original y la tasa de descuento y calcula el precio rebajado; la información de tipos, que indica que recibe valores <code>number</code> y devuelve un <code>number</code>, respalda esa interpretación. No necesitamos conocer la lógica de cálculo aplicada internamente.</p>
<p>En cambio, <code>calculate(price: number, rate: number): number</code> no aporta información sobre qué calcula, así que no podemos prever el resultado. Al final tenemos que abrir el código fuente para poder utilizarla y se pierde la ventaja de la abstracción.</p>
<p>Conviene observar que la propia manera de nombrar refleja el nivel de abstracción. En programación, los nombres de las funciones suelen combinar <strong>verbo + sustantivo</strong>, y el verbo elegido revela en qué nivel de abstracción opera la función.</p>
<blockquote>
<p>Sin embargo, el verbo por sí solo no determina el nivel de abstracción. El sustantivo que lo acompaña —el contexto de dominio— es lo que determina el nivel final.</p>
</blockquote>
<p>Hay verbos frecuentes en los niveles de abstracción <strong>bajos</strong>, como <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> o <code>transform</code>. Estas palabras sugieren transformaciones físicas de los datos o manipulaciones directas de estructuras de datos.</p>
<p>En un nivel intermedio aparecen verbos como <code>get</code>, <code>save</code>, <code>load</code> o <code>validate</code>. Son operaciones técnicas, pero su intención ya resulta visible hasta cierto punto.</p>
<p>En los niveles de abstracción <strong>altos</strong> se utilizan verbos como <code>register</code>, <code>refund</code>, <code>confirm</code>, <code>cancel</code> o <code>submit</code>. Son palabras propias del dominio de negocio. No revelan en absoluto qué procedimiento técnico ocurre en el interior; solo expresan <strong>la acción del usuario o el proceso de negocio</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>En <strong>Clean Code</strong>, Robert C. Martin afirmó al respecto que <strong>«es mejor un nombre largo y descriptivo que uno corto y enigmático»</strong>. También propuso el principio <strong>«usa una palabra por concepto»</strong>, porque si mezclamos <code>fetch</code>, <code>retrieve</code> y <code>get</code> para operaciones del mismo contexto, quien lea el código se preguntará: «¿Son tres operaciones distintas?».</p>
<p>Este principio se aplica igualmente a los nombres de componentes y hooks de React.</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>La especificidad del nombre de un componente varía según su nivel de abstracción. Button se utiliza como un primitivo de UI genérico de bajo nivel, mientras que SubmitOrderButton expresa con claridad una intención de negocio de alto nivel.</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> es el nombre de la prop que el componente expone al exterior. Desde el código que utiliza el componente se declara «a qué evento reaccionar». <code>handle*</code> es el nombre de la función de implementación que se pasa realmente a esa 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>Los hooks personalizados utilizan el prefijo <code>use</code> para cumplir las reglas de React y permiten que el componente use el estado o las operaciones que ofrece el hook.</p>
<blockquote>
<p>Jeff Atwood, creador de <a href="https://blog.codinghorror.com/" target="_blank" rel="noopener noreferrer">Coding Horror</a>, señaló en una ocasión el problema del sufijo <code>Manager</code>. Un nombre como <code>UrlManager</code> no permite saber en absoluto si mantiene un pool de URL, las valida o las crea. Nombres como <code>UrlBuilder</code>, <code>UrlValidator</code> o <code>UrlPool</code>, que revelan una responsabilidad concreta, son mucho mejores. Un nombre ambiguo puede ser una señal de que la propia responsabilidad del módulo también lo es.</p>
</blockquote>
<p>En definitiva, un buen nombre es <strong>aquel que indica de inmediato a quien lee en qué nivel de abstracción opera el código</strong>.</p>
<h3 id="diseñar-deliberadamente-la-libertad-de-las-entradas"><a class="anchor" href="#diseñar-deliberadamente-la-libertad-de-las-entradas">Diseñar deliberadamente la libertad de las entradas</a></h3>
<p>Al diseñar un módulo abstraído aparece con frecuencia una pregunta: «¿Hasta qué punto debemos dejar abierta la funcionalidad?». Esta decisión cambia considerablemente la experiencia de los desarrolladores que utilizan el módulo.</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>El primer botón solo acepta <code>children</code>. No permite configurar <code>onClick</code>, <code>type</code> ni <code>disabled</code>. A cambio, quien lo utiliza no tiene nada que decidir.</p>
<p>El segundo botón acepta todos los atributos del elemento <code>button</code>. Ofrece mucha libertad, pero obliga a quien lo utiliza a decidir cuál de las decenas de props disponibles debe usar. Describo estas situaciones diciendo que <strong>«el componente obliga al desarrollador a pensar»</strong>.</p>
<p>No hay una respuesta única. Debemos encontrar el nivel adecuado según el propósito del módulo y sus usuarios. En el botón base de un sistema de diseño puede ser preferible restringir las Props para mantener la coherencia; en un componente utilitario genérico puede convenir abrirlas para ofrecer flexibilidad.</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>La amplitud de una abstracción debe decidirse en función de quién la utiliza. Para un usuario que necesita comprender la implementación interna y ejercer un control preciso, es adecuada una interfaz de bajo nivel. En cambio, ofrecer una interfaz demasiado abierta a quien no necesita conocer los detalles solo añade confusión. Y, a la inversa, si restringimos en exceso las entradas de alguien que debe responder a situaciones variadas, impediremos los propios casos de uso.</p>
<h3 id="mantener-una-unidad-de-abstracción-adecuada"><a class="anchor" href="#mantener-una-unidad-de-abstracción-adecuada">Mantener una unidad de abstracción adecuada</a></h3>
<p>La unidad de abstracción —es decir, «hasta dónde agrupar en un solo módulo»— también es una decisión importante.</p>
<p>Un antipatrón frecuente en frontend es la extracción excesiva de 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>Si separamos innecesariamente en un hook una lógica que solo utiliza un componente, obligamos a quien lee el código a saltar entre dos archivos para comprender el contexto. En lugar de reducirlo, la abstracción lo ha aumentado.</p>
<p>El extremo contrario —incluir demasiadas cosas en un solo hook— también supone un problema.</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>Este tipo de «God Hook» es difícil de probar, y modificar un elemento puede afectar a partes que no guardan relación con él.</p>
<p>El criterio para determinar una unidad de abstracción adecuada es <strong>«¿Esta separación reduce realmente el contexto de quien lee el código?»</strong>. Si, como resultado de la separación, el contexto queda disperso y cuesta más comprenderlo, todavía no ha llegado el momento de esa abstracción.</p>
<h2 id="cuidado-con-las-abstracciones-prematuras"><a class="anchor" href="#cuidado-con-las-abstracciones-prematuras">Cuidado con las abstracciones prematuras</a></h2>
<p>Después de leer hasta aquí puede quedar una pregunta: «Entonces, ¿cuándo debemos abstraer?». Mi opinión es la siguiente: <strong>la premisa básica debe ser no abstraer de forma prematura</strong>.</p>
<p>Mientras no aparezca una señal clara para abstraer, dejar el código tal como está suele ofrecer un resultado mínimo mejor que crear una abstracción incorrecta y tener que deshacerla después. El proceso por el que surge una mala abstracción suele parecerse a este:</p>
<ol>
<li>Aparece un patrón similar en el código A y el código B.</li>
<li>Pensamos: «¡El principio DRY dice que debo extraerlo a una función común!» y lo abstraemos.</li>
<li>Encontramos un patrón parecido en el código C y usamos la misma función, pero añadimos un parámetro para introducir un comportamiento ligeramente distinto.</li>
<li>Al empezar a utilizarla también en el código D y E, siguen aumentando las condiciones y los parámetros.</li>
<li>La función ya se utiliza en todas partes, pero se ha convertido en un código que nadie se atreve a tocar.</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>Si hemos caído en esta situación, la solución está clara: volver a poner inline el código abstraído en cada lugar donde se utiliza, eliminar en cada caso el código innecesario y, cuando el estado quede limpio, abstraer de nuevo si entonces aparece una coincidencia real. <strong>«La forma más rápida de avanzar es volver atrás»</strong>.</p>
<p>Entonces, ¿cuándo debemos abstraer? Estas son, a grandes rasgos, las <strong>señales de abstracción</strong> que percibo:</p>
<ul>
<li><strong>Se está perdiendo la coherencia</strong>: Aunque se trata de la misma lógica, en algunos componentes aparece inline y en otros se ha separado en una función. La misma lógica de cálculo está dispersa por todas partes.</li>
<li><strong>La estructura interna se expone innecesariamente al exterior</strong>: Quien llama al módulo tiene que manejar uno por uno detalles de implementación que no necesita conocer.</li>
<li><strong>El procedimiento interno sigue quedando expuesto</strong>: El módulo no logra ocultar su propio procedimiento y quien lo utiliza debe seguirlo tal cual.</li>
</ul>
<p>El problema es que detectar estas señales suele ser bastante sencillo, pero <strong>en la práctica es fácil ignorarlas y centrarse en satisfacer requisitos más «importantes»</strong>. Cuando nos presionan los plazos o estamos absortos en implementar una funcionalidad, pensamos: «Ya funciona; lo ordenaré más adelante». Y ese momento rara vez llega.</p>
<p>Hay otra cuestión importante: <strong>mantener criterios de abstracción coherentes</strong>. Si dentro del mismo código base una misma clase de lógica aparece inline en un lugar, como función en otro y como hook personalizado en un tercero, quien lea ese código por primera vez se preguntará: «¿Hay una intención detrás de esta diferencia?». Tanto si se abstrae como si no, el equipo debe aplicar criterios coherentes.</p>
<p>También merece la pena recordar en este contexto <strong>«la ley de las abstracciones con fugas (The Law of Leaky Abstractions)»</strong>, formulada por Joel Spolsky en 2002. Esta ley sostiene que, aunque una abstracción intenta ocultar una implementación compleja, sus detalles terminan filtrándose al exterior (leak). Es decir: algo se diseña para que el usuario de la abstracción no necesite conocer la implementación interna, pero en la práctica surgen situaciones en las que solo puede utilizarla correctamente si la conoce.</p>
<p>TCP abstrae una red inestable para hacerla parecer una conexión fiable, pero la abstracción se rompe si se desconecta el cable. React abstrae de forma declarativa las actualizaciones de la UI, pero para optimizar los rerenderizados acabamos necesitando comprender su funcionamiento interno. Como no existen abstracciones perfectas, al abstraer debemos plantearnos incluso <strong>«¿Puede el usuario responder cuando esta abstracción se rompa?»</strong>.</p>
<p>En definitiva, <strong>«la abstracción nos ahorra tiempo de trabajo, pero no tiempo de aprendizaje»</strong>.</p>
<h2 id="la-abstracción-se-interioriza-con-la-práctica"><a class="anchor" href="#la-abstracción-se-interioriza-con-la-práctica">La abstracción se interioriza con la práctica</a></h2>
<p>En una ocasión hablé con un compañero sobre la abstracción y hubo una idea que me resultó especialmente memorable: detectar las señales de abstracción y separar algo en el nivel adecuado y en el momento oportuno pertenece, en última instancia, al <strong>terreno de la intuición</strong>.</p>
<p>Por supuesto, los principios mencionados antes —mantener el nivel de abstracción, elegir buenos nombres y diseñar la libertad de las entradas— son importantes. Sin embargo, intentar recordar cada uno mientras escribimos código y preguntarnos constantemente «¿Debería separar esto o no?» puede llegar a romper el flujo. Igual que, si durante un combate somos demasiado conscientes del ángulo del codo al lanzar un jab, podemos perder el momento preciso, al programar la abstracción debe surgir de una intuición natural y no de un juicio consciente.</p>
<p>Hay momentos en los que estamos escribiendo código y, de pronto, algo nos produce rechazo. Sentimos: «Esta lógica no debería estar aquí» o «Este componente parece saber demasiado». Esa sensación es precisamente una señal de abstracción; interiorizarla significa poder detectarla y responder a ella de forma natural.</p>
<p>Pero esta intuición no se desarrolla de la noche a la mañana. Solo después de aprender multitud de patrones, leer código diverso y sufrir nuestros propios tropiezos empieza a surgir con naturalidad la sensación de <strong>«creo que esto debería separarse»</strong>. Si más tarde un compañero pregunta «¿Por qué lo separaste?» y podemos explicar con naturalidad «Lo separé porque es X», entonces lo hemos interiorizado.</p>
<p>Creo que ocurre lo mismo en cualquier disciplina. Intentar hacerlo bien a base de memorizar puede dificultar aún más las decisiones. Al final, hay que conservar el flujo general y dejar que los detalles se completen de forma natural. Y esa naturalidad nace de la variedad de patrones y experiencias acumulados en el día a día.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En programación, abstraer es ocultar la complejidad para que algo parezca sencillo y permitir que quien lee el código se concentre únicamente en el contexto que necesita.</p>
<p>Recapitulemos lo que conviene recordar para construir buenas abstracciones:</p>
<ul>
<li>Como principio básico, evitemos abstraer de forma prematura y separemos solo cuando aparezca una señal clara.</li>
<li>Hagamos que una función hable desde <strong>un único nivel de abstracción</strong>.</li>
<li><strong>Expresemos</strong> suficientemente el comportamiento mediante nombres y tipos para que pueda utilizarse sin abrir el código fuente.</li>
<li><strong>Diseñemos deliberadamente</strong> la libertad de las entradas según el propósito del módulo y sus usuarios.</li>
<li>En vez de seguir añadiendo capas sobre una abstracción incorrecta, tengamos <strong>el valor de deshacerla y empezar de nuevo</strong>.</li>
<li>E <strong>interioricemos distintos patrones</strong> hasta que todo esto surja de forma natural, sin necesidad de pensarlo conscientemente.</li>
</ul>
<p>Por supuesto, lo que he expuesto en este artículo no es la única respuesta correcta. El nivel adecuado de abstracción puede variar según la situación del negocio, la composición del equipo y la naturaleza del proyecto. Sin embargo, hay algo que no cambia: el objetivo último de la abstracción es <strong>crear código que las personas puedan comprender con facilidad</strong>.</p>
<p>Espero que quienes lean este artículo se planteen también en sus propios códigos base la pregunta: «¿Esta abstracción está reduciendo realmente el contexto?». Creo que esa sola pregunta puede cambiar, aunque sea un poco, la forma de mirar el código.</p>
<h2 id="referencias"><a class="anchor" href="#referencias">Referencias</a></h2>
<p>Este artículo se inspira en gran medida en distintos documentos oficiales y artículos anteriores. Dejo también las fuentes de las citas directas y los textos que me ayudaron a estructurar estas ideas.</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, 추상, 그리고 추상화</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>diseño</category>
            <category>abstracción</category>
        </item>
        <item>
            <title><![CDATA[queryKey]]></title>
            <link>https://hooninedev.com/es/260104</link>
            <guid isPermaLink="false">https://hooninedev.com/es/260104</guid>
            <pubDate>Sun, 04 Jan 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta publicación quiero hablar sobre queryKey de TanStack Query. Está dirigida a desarrolladores frontend que usan TanStack Query y dudan entre dejar las queryKey como arreglos en línea, reunirlas ...]]></description>
            <content:encoded><![CDATA[<p>En esta publicación quiero hablar sobre <strong>queryKey de TanStack Query</strong>.</p>
<p>Está dirigida a desarrolladores frontend que usan TanStack Query y dudan entre dejar las queryKey como arreglos en línea, reunirlas en un objeto de constantes o en una fábrica de claves, o pasarlas a <code>queryOptions</code> de v5. Al terminar sabrás qué problema vino a resolver cada enfoque y tendrás un criterio para juzgar qué etapa encaja hoy con tu código.</p>
<p>Al usar TanStack Query en proyectos reales, he tenido que <strong>replantear varias veces la forma de gestionar queryKey</strong>. Al principio escribía directamente en los componentes arreglos como <code>['user', userId]</code>, pero, cada vez que tenía que invalidar una consulta, acababa repitiendo la misma clave en varios lugares y cometiendo errores tipográficos. Por eso las trasladé a un objeto de constantes como <code>QUERY_KEYS</code>. Más adelante, tras leer un artículo de TkDodo, adopté el patrón de fábrica de claves de consulta; bastante tiempo después incorporé la librería <code>@lukemorales/query-key-factory</code>; y, cuando apareció v5, volví a reorganizarlo todo en torno a <code>queryOptions</code>.</p>
<h2 id="antes-de-que-existiera-querykey"><a class="anchor" href="#antes-de-que-existiera-querykey">Antes de que existiera queryKey</a></h2>
<p>Antes de entrar de lleno en el tema, conviene detenernos en una cuestión. Hoy usamos con total naturalidad librerías como <code>TanStack Query</code> y <code>SWR</code>, pero ¿cómo se gestionaban los datos asíncronos antes de que existieran?</p>
<p>La forma más habitual probablemente consistía en combinar <code>useState</code>, <code>useEffect</code>, <code>fetch</code>, <code>axios</code> y herramientas similares.</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>El problema de este código es evidente. Basta con que haya dos componentes en la página mostrando el mismo <code>userId</code> para que <strong>la misma petición se envíe dos veces</strong>. La razón es que no existe una caché. Además, si el usuario visita otra página y después regresa, los datos vuelven a solicitarse desde cero. Como no hay forma de distinguir si se obtuvieron hace un segundo o hace una hora, también resulta difícil reproducir comportamientos como «mostrar el valor almacenado en caché mientras se actualiza en segundo plano». (Podríamos implantar nuestro propio sistema de caché, pero considero que gestionarlo sería bastante complicado.)</p>
<p>Para resolverlo apareció la combinación Redux + redux-thunk (o redux-saga). Al extraer la lógica de obtención de datos a un thunk y guardar el resultado en el almacén, otros componentes podían reutilizar los mismos datos. Sin embargo, había que definir tipos de acción, escribir reductores y gestionar manualmente los estados de carga, éxito y error en cada ocasión. La cantidad de código repetitivo necesaria para obtener un solo dato era enorme. (Empecé a trabajar profesionalmente en esa época y me preguntaba: «¿Por qué tengo que crear varios archivos para obtener un único dato?».)</p>
<p>En el fondo, todo este recorrido se reduce a lo siguiente: <strong>«Para evitar repetir una petición, debemos poder identificar de qué petición se trata»</strong>. Y el identificador que responde a «de qué petición se trata» es precisamente queryKey.</p>
<p>SWR y React Query (hoy TanStack Query) abordaron el problema de frente: «Toda petición asíncrona debe tener un identificador y, si el identificador es el mismo, debe compartir la caché». Este único y sencillo principio eliminó todo el código repetitivo anterior.</p>
<h2 id="la-esencia-de-querykey"><a class="anchor" href="#la-esencia-de-querykey">La esencia de queryKey</a></h2>
<p>Entonces, ¿qué es exactamente queryKey? La documentación oficial de TanStack Query lo define así.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>En esencia, TanStack Query gestiona el almacenamiento en caché de las consultas a partir de sus claves. En el nivel superior, las claves de consulta deben ser un arreglo... Siempre que la clave de consulta se pueda serializar y sea <strong>exclusiva de los datos de la consulta</strong>, puede utilizarse.</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>Hay dos ideas esenciales: <strong>debe poder serializarse y debe ser exclusiva de esos datos</strong>. Una misma clave representa los mismos datos, y datos distintos deben tener claves distintas. Esta sencilla regla determina el funcionamiento de todo el sistema de caché.</p>
<p>Hay, además, otro aspecto importante: <strong>queryKey actúa al mismo tiempo como arreglo de dependencias</strong>. Del mismo modo que en <code>useEffect</code> de React el efecto vuelve a ejecutarse cuando cambian sus dependencias, cuando cambia queryKey TanStack Query obtiene automáticamente los datos nuevos.</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>Cuando <code>userId</code> es <code>'A'</code> y cuando es <code>'B'</code>, las queryKey son diferentes. Si son diferentes, se produce un fallo de caché; si hay un fallo de caché, se obtienen los datos. Todo ocurre automáticamente. Gracias a esta sencillez, no tenemos que escribir por nuestra cuenta la lógica de «como userId ha cambiado, hay que volver a obtener los datos».</p>
<p>Entonces, ¿cómo reconoce TanStack Query como la misma clave un arreglo que se crea de nuevo en cada renderizado? No compara las referencias de los arreglos, sino que usa como clave de caché la cadena que resulta de serializar la queryKey con <code>hashKey</code> (<code>queryHash</code>). Esta función ordena las claves de los objetos planos mientras serializa con <code>JSON.stringify</code>, así que el orden de las claves dentro de un objeto no afecta al hash, pero el orden de los elementos de un arreglo sí. Una propiedad cuyo valor es <code>undefined</code> desaparece durante la serialización, de modo que <code>{ a: 1, b: undefined }</code> y <code>{ a: 1 }</code> resultan ser la misma clave. He recogido aparte este comportamiento, siguiendo el código de la implementación, en <a href="/251230" target="_blank" rel="noopener noreferrer">Cómo se comparan las queryKey</a>.</p>
<h2 id="reglas-para-escribir-querykey"><a class="anchor" href="#reglas-para-escribir-querykey">Reglas para escribir queryKey</a></h2>
<p>Una vez que se entiende que la queryKey es a la vez el identificador de la caché y un arreglo de dependencias, las reglas de escritura se deducen de forma natural. Las recomendaciones de la documentación oficial pueden resumirse así.</p>
<h3 id="siempre-un-arreglo"><a class="anchor" href="#siempre-un-arreglo">Siempre un arreglo</a></h3>
<p>Aunque pasar una cadena también funciona (internamente se convierte en un arreglo), conviene usar un arreglo desde el principio para mantener la coherencia.</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="todas-las-variables-que-usa-queryfn"><a class="anchor" href="#todas-las-variables-que-usa-queryfn">Todas las variables que usa queryFn</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>Es la misma forma de pensar que con las dependencias de <code>useEffect</code>. Todas las variables utilizadas dentro de la función deben formar parte de la clave (= dependencia). Si se incumple esta regla, pueden aparecer errores difíciles de rastrear, como que los datos del usuario anterior sigan mostrándose después de cambiar a otro usuario.</p>
<h3 id="de-lo-genérico-a-lo-específico"><a class="anchor" href="#de-lo-genérico-a-lo-específico">De lo genérico a lo específico</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>Este orden es importante por la <strong>invalidación (invalidation)</strong>. De forma predeterminada, <code>invalidateQueries</code> de TanStack Query utiliza <strong>coincidencia por prefijo</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>Si las claves se diseñan como una estructura de árbol, se puede expresar en una sola línea desde «vuelve a obtener todos los datos de este dominio» hasta «vuelve a obtener únicamente este elemento concreto». (A primera vista puede parecer poco importante, pero su valor se vuelve evidente después de diseñarlo mal una vez y comprobar que el alcance de la invalidación no se comporta como esperabas.)</p>
<h2 id="evolución-de-la-gestión-de-querykey"><a class="anchor" href="#evolución-de-la-gestión-de-querykey">Evolución de la gestión de queryKey</a></h2>
<p>Hasta aquí hemos visto el funcionamiento y el uso de queryKey. Pasemos ahora a la pregunta principal: <strong>¿cómo ha ido cambiando su gestión?</strong></p>
<p>Voy a ordenar cronológicamente las etapas por las que he pasado en proyectos reales.</p>
<h3 id="arreglos-en-línea"><a class="anchor" href="#arreglos-en-línea">Arreglos en línea</a></h3>
<p>Es la forma más sencilla. Dentro del componente se combinan cadenas fijas con valores de las propiedades.</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>Al comenzar, esto puede ser suficiente.</p>
<p>El problema aparece cuando crece la base de código. Hay que invalidar desde una mutación que modifica la información del usuario, pero cada vez es necesario buscar «¿cuál era la clave de las consultas relacionadas con usuarios?». Unos lugares utilizan <code>['user', userId]</code> y otros <code>['users', userId]</code> (en plural). Como ocupan espacios de caché completamente distintos, la invalidación solo se aplica a uno de ellos.</p>
<h3 id="objeto-de-constantes"><a class="anchor" href="#objeto-de-constantes">Objeto de constantes</a></h3>
<p>Para evitar errores tipográficos, las claves de consulta se agrupan como constantes.</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>Los errores tipográficos desaparecen, pero la responsabilidad de componer la clave sigue recayendo en cada lugar de uso. Alguien escribe la combinación <code>[QUERY_KEYS.USER, userId]</code> como <code>[QUERY_KEYS.USER, userId, 'detail']</code>, mientras que otra persona usa <code>['user', 'detail', userId]</code>. Llega un momento en el que hay que memorizar por separado qué convención es la correcta.</p>
<h3 id="fábrica-de-claves-de-consulta"><a class="anchor" href="#fábrica-de-claves-de-consulta">Fábrica de claves de consulta</a></h3>
<p>Este patrón quedó concretado en el artículo <a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">Claves eficaces para React Query</a>, de TkDodo. Consiste en definir un objeto que crea las claves de cada dominio y expresar la jerarquía mediante funciones.</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>Este patrón es potente porque <strong>la jerarquía queda expresada de forma explícita en el código</strong>. <code>todoKeys.all</code> apunta a todas las consultas relacionadas con tareas, <code>todoKeys.lists()</code> a todas las consultas de tipo lista y <code>todoKeys.detail(1)</code> a un elemento concreto. El alcance de la invalidación puede expresarse con precisión en una línea de código.</p>
<p>Otra ventaja es la <strong>colocación conjunta (co-location)</strong>. TkDodo no recomienda reunir las claves en un archivo global. En su lugar, propone colocar <code>queries.ts</code> dentro del directorio de la funcionalidad y mantener allí tanto las claves como los hooks.</p>
<pre><code>src/
└── features/
    └── todos/
        ├── index.tsx
        └── queries.ts   # 키와 훅을 모두 여기에
</code></pre>
<p>Así se obtiene un modelo mental sencillo: «para modificar algo relacionado con tareas, basta con mirar la carpeta de tareas». Es una aplicación fiel del principio de mantener juntas las cosas que cambian juntas.</p>
<h3 id="lukemoralesquery-key-factory"><a class="anchor" href="#lukemoralesquery-key-factory">@lukemorales/query-key-factory</a></h3>
<p>Al escribir manualmente una y otra vez el tercer patrón, el código repetitivo se acumula. Además, cuando se quieren combinar y gestionar las claves de varios dominios, se echa en falta una interfaz normalizada. <a href="https://github.com/lukemorales/query-key-factory" target="_blank" rel="noopener noreferrer">@lukemorales/query-key-factory</a> es el resultado de convertir este patrón en una librería.</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> añade automáticamente el prefijo, y <code>mergeQueryKeys</code> permite combinar dominios. Además, la propiedad convenida <code>_def</code> da acceso a la clave de todo el dominio. Desaparece así la necesidad de añadir <code>as const</code> en cada fábrica manual para restringir los tipos por cuenta propia.</p>
<p>Durante un tiempo, esta librería se utilizó prácticamente como un estándar. (Yo también la usé con frecuencia durante bastante tiempo.) Sin embargo, la aparición de queryOptions cambió la situación.</p>
<h3 id="queryoptions-api-oficial-de-v5"><a class="anchor" href="#queryoptions-api-oficial-de-v5">queryOptions (API oficial de v5)</a></h3>
<p>Uno de los cambios más importantes de TanStack Query v5 fue la introducción de la API <code>queryOptions</code>. Con el paso de v4 a v5, los argumentos de todos los hooks se unificaron en un único objeto. El verdadero propósito de ese cambio era permitir extraer ese objeto como <strong>unidad reutilizable</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>A primera vista puede surgir la duda: «¿Qué tiene esto de diferente? Parece simplemente un objeto envuelto en una función». TkDodo también lo reconoce en su artículo <a href="https://tkdodo.eu/blog/the-query-options-api" target="_blank" rel="noopener noreferrer">La API Query Options</a>. Durante la ejecución, se limita realmente a devolver el mismo objeto que recibe.</p>
<p>El trabajo verdaderamente útil ocurre <strong>dentro del sistema de tipos</strong>. Veámoslo a continuación.</p>
<h2 id="datatag-de-queryoptions"><a class="anchor" href="#datatag-de-queryoptions">DataTag de queryOptions</a></h2>
<p><code>queryOptions</code> no es una simple función auxiliar porque <strong>incorpora información sobre el tipo de los datos en la queryKey devuelta</strong>. Dentro de TanStack Query, este mecanismo se denomina <code>DataTag</code>.</p>
<p>Su implementación aproximada es la siguiente.</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>Se trata de un <strong>tipo con marca (branded type)</strong> basado en <code>unique symbol</code>. Es solo una marca sin ningún efecto durante la ejecución, pero para TypeScript contiene la información de que «este arreglo no es un simple arreglo, sino un arreglo asociado a datos del tipo <code>TValue</code>».</p>
<p>Existe una razón concreta para utilizar <code>unique symbol</code>. El artículo de Zenn <a href="https://zenn.dev/tsuboi/articles/tanstack-query-options-unique-symbol?locale=en" target="_blank" rel="noopener noreferrer">El unique symbol que se oculta tras DataTag</a> compara este recurso con «una plaza de aparcamiento exclusiva para la información de tipos». Si se utilizase una clave de cadena normal, podría colisionar con una clave de otra librería o del código del usuario; sin embargo, <strong>cada declaración <code>unique symbol</code> crea por sí misma un tipo único</strong>, por lo que nunca coincide con otra declaración. Se convierte así en un identificador que no puede colisionar.</p>
<p>La diferencia que produce este único mecanismo es considerable.</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>Aunque <code>getQueryData</code> y <code>setQueryData</code> solo reciben una queryKey, como esta ya lleva incorporado el tipo de datos, el tipo devuelto se infiere automáticamente. No es necesario proporcionar los genéricos a mano y, si se intenta introducir en <code>setQueryData</code> un valor de un tipo incorrecto, el compilador lo detecta de inmediato.</p>
<p>Por supuesto, también existen limitaciones. En métodos como <code>getQueriesData</code>, que obtienen varias consultas a la vez, el resultado es un arreglo de tuplas heterogéneas y no se aplica la inferencia de tipos. Además, el uso de <code>unique symbol</code> puede provocar un error TS4023 al generar archivos <code>.d.ts</code> en un monorepositorio; se evita importando <code>dataTagSymbol</code> de forma explícita.</p>
<p>Al resumir el mecanismo visto hasta aquí, queda claro un hecho: <strong>la inferencia de tipos de queryOptions depende por completo de que queryKey y queryFn se declaren juntas en un mismo lugar</strong>. Para incorporar en queryKey el tipo que devuelve queryFn, ambas deben declararse juntas.</p>
<p>Esto tiene una implicación de peso para la dirección de diseño de las fábricas de claves de consulta. Los patrones de la generación anterior se centraban en separar la gestión de queryKey como una unidad de abstracción independiente. Sin embargo, la recomendación de v5 va en la dirección opuesta: <strong>volver a unir queryKey y queryFn en una sola unidad</strong>. TkDodo llega a afirmar que «separar queryKey y queryFn fue un error». Al fin y al cabo, la clave es el conjunto de dependencias que utiliza la función, y ambas mantienen una relación inseparable.</p>
<h2 id="patrón-práctico-de-composición-con-queryoptions"><a class="anchor" href="#patrón-práctico-de-composición-con-queryoptions">Patrón práctico de composición con queryOptions</a></h2>
<p>El verdadero potencial de <code>queryOptions</code> aparece al combinarla con una fábrica por dominio. La forma recomendada por la documentación oficial de v5 es la siguiente.</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>Veamos una por una las razones por las que este patrón resulta útil.</p>
<h3 id="jerarquía-e-inferencia-de-tipos"><a class="anchor" href="#jerarquía-e-inferencia-de-tipos">Jerarquía e inferencia de tipos</a></h3>
<p><code>todoQueries.all()</code> y <code>todoQueries.lists()</code> devuelven simples arreglos, mientras que <code>todoQueries.detail(1)</code> devuelve mediante <code>queryOptions</code> un objeto con una etiqueta de datos. Para invalidar se usa el arreglo; para invocar la consulta, el objeto de opciones.</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="opciones-sobrescritas-por-componente"><a class="anchor" href="#opciones-sobrescritas-por-componente">Opciones sobrescritas por componente</a></h3>
<p>Como el resultado de <code>queryOptions</code> sigue siendo un objeto, en el momento de la llamada pueden combinarse algunas opciones.</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>Este patrón es especialmente potente porque el tipo devuelto por <code>select</code> se infiere automáticamente y el tipo de <code>data</code> se restringe a <code>string</code>. Desde el punto de vista del componente, es posible seleccionar solo la parte necesaria y mantener la definición del dominio intacta en un único lugar.</p>
<h3 id="menos-hooks-personalizados-sobre-usequery"><a class="anchor" href="#menos-hooks-personalizados-sobre-usequery">Menos hooks personalizados sobre <code>useQuery</code></a></h3>
<p>Durante la época de v4, el patrón habitual consistía en crear hooks personalizados para cada dominio.</p>
<p>El problema era que, <strong>en cuanto se necesitaba una precarga, había que volver a escribir la misma definición</strong>. Como <code>useTodoDetail</code> no puede llamarse fuera de un componente, en el cargador del enrutador o en un manejador de eventos había que volver a escribir <code>queryClient.prefetchQuery({ queryKey: [...], queryFn: ... })</code>.</p>
<p>Con <code>queryOptions</code>, esa duplicación desaparece.</p>
<p>Una sola definición funciona en cualquier lugar. Por eso TkDodo recomienda «en v5, define queryOptions en lugar de crear hooks». Estos hooks quedan como una capa ligera que solo se añade cuando es necesaria, mientras que la definición del dominio puede existir de manera autosuficiente sin ellos.</p>
<h2 id="invalidación-tras-una-mutación"><a class="anchor" href="#invalidación-tras-una-mutación">Invalidación tras una mutación</a></h2>
<p>El lugar en el que la jerarquía de queryKey brilla de verdad es la invalidación posterior a una mutación. Según la documentación de TanStack Query sobre <a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-invalidation" target="_blank" rel="noopener noreferrer">invalidación de consultas</a>, <code>invalidateQueries</code> utiliza de forma predeterminada la <strong>coincidencia por prefijo</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 관련 쿼리 (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>Si las claves se diseñan de forma jerárquica, <strong>el alcance de la invalidación coincide con el significado del código</strong>. «Actualiza todas las tareas» se expresa con <code>all()</code>, «actualiza solo las listas» con <code>lists()</code> y «actualiza solo este elemento» con <code>detail(id)</code>.</p>
<p>Si las claves estuvieran dispersas de forma plana, como <code>['todoList']</code> y <code>['todoDetail', 1]</code>, para invalidar «todo el dominio de tareas» habría que realizar dos llamadas separadas o crear y gestionar una constante de prefijo adicional. (Y, si al añadir una nueva clave al dominio se olvidase incorporarla a esa constante, se produciría un error que la dejaría fuera de la invalidación.)</p>
<h2 id="recuperar-querykey-dentro-de-queryfn"><a class="anchor" href="#recuperar-querykey-dentro-de-queryfn">Recuperar queryKey dentro de queryFn</a></h2>
<p>Por último, hay otro patrón que merece atención. En realidad, <code>queryFn</code> recibe como argumento un objeto llamado <code>QueryFunctionContext</code>, que contiene la queryKey del momento de la llamada.</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>¿Por qué resulta útil este patrón? Según <a href="https://tkdodo.eu/blog/leveraging-the-query-function-context" target="_blank" rel="noopener noreferrer">Cómo aprovechar el contexto de la función de consulta</a>, de TkDodo, permite <strong>forzar la sincronización entre las dependencias de queryKey y queryFn</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>Este código es peligroso porque queryFn depende de una variable externa. Además, aunque cambie <code>sortBy</code>, la caché no se actualiza porque esa dependencia no está incluida en la clave. Mientras <code>queryFn</code> tome variables de un cierre externo, siempre será posible cometer este error.</p>
<p>La solución es sencilla: hacer que <code>queryFn</code> no dependa de variables externas. <strong>Si todas las dependencias se extraen de queryKey</strong>, una variable que no figure en queryKey ni siquiera podrá utilizarse dentro de la función.</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>Con esta estructura, cuando aparece una dependencia nueva no existe forma de usarla dentro de la función sin añadirla a queryKey. El compilador detectará que «esa clave no existe». La sincronización entre la clave y la función deja de depender de una convención y se <strong>delega al sistema de tipos</strong>.</p>
<h2 id="hasta-dónde-separar"><a class="anchor" href="#hasta-dónde-separar">Hasta dónde separar</a></h2>
<p>Después de leer todo lo anterior puede surgir una pregunta: «Entonces, ¿hay que extraer todas las consultas a <code>queryOptions</code>?».</p>
<p>Mi respuesta, como casi siempre, es: <strong>«depende de la situación»</strong>.</p>
<p>Conviene recordar que <strong>una abstracción no siempre es beneficiosa</strong>. Si una consulta solo se utiliza una vez, extraerla innecesariamente a una fábrica de dominio solo obliga a quien lee el código a desplazarse entre dos archivos. La evolución de los patrones de gestión de queryKey no significa «hay que usar siempre la herramienta más sofisticada», sino que <strong>«existe la posibilidad de subir un peldaño cuando resulte necesario»</strong>.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En resumen, queryKey es <strong>la unidad fundamental con la que TanStack Query identifica y almacena en caché los datos asíncronos</strong>. En ese pequeño arreglo se concentran el identificador del espacio de caché, el arreglo de dependencias, el alcance de la invalidación y, desde v5, incluso la información sobre el tipo de los datos. Precisamente porque tantas responsabilidades convergen en un único punto, la forma de escribirlo y gestionarlo influye directamente en la carga cognitiva de toda la base de código.</p>
<p>Cada etapa fue la respuesta a un problema real que alguien encontró en su momento. Por eso, la conclusión no debe ser simplemente «como ahora estamos en v5, hay que usar exclusivamente <code>queryOptions</code>», sino <strong>«primero hay que identificar qué clase de problema está experimentando actualmente mi base de código»</strong>. Introducir una fábrica de dominio en un proyecto donde bastan los arreglos en línea puede constituir por sí mismo un exceso de diseño.</p>
<p>Espero que quienes lean este artículo revisen también sus propios proyectos: cómo se distribuyen las queryKey por toda la base de código, cómo se realizan las invalidaciones y si esa estructura se ajusta al tamaño actual del equipo y a la complejidad del dominio.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner">[documentación] <a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query, claves de consulta</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[documentación] <a href="https://tanstack.com/query/v5/docs/framework/react/guides/query-options" target="_blank" rel="noopener noreferrer">TanStack Query, opciones de consulta</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[documentación] <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">[artículo] <a href="https://tanstack.com/blog/announcing-tanstack-query-v5" target="_blank" rel="noopener noreferrer">TanStack, presentación de 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[Cómo se comparan las queryKey]]></title>
            <link>https://hooninedev.com/es/251230</link>
            <guid isPermaLink="false">https://hooninedev.com/es/251230</guid>
            <pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta publicación quiero hablar sobre cómo decide TanStack Query que dos queryKey son la misma clave. Está dirigida a quienes usan TanStack Query y se han preguntado por qué una queryKey, que se cre...]]></description>
            <content:encoded><![CDATA[<p>En esta publicación quiero hablar sobre <strong>cómo decide TanStack Query que dos queryKey son la misma clave</strong>.</p>
<p>Está dirigida a quienes usan TanStack Query y se han preguntado por qué una queryKey, que se crea como un arreglo nuevo en cada renderizado, no provoca un fallo de caché cada vez, y si el orden de las claves de un objeto o los valores <code>undefined</code> afectan a la caché; al terminar sabrás qué regla hace que dos claves sean iguales y cómo afecta esa regla a la caché. Para adelantar la conclusión: TanStack Query usa como clave de caché la cadena que resulta de serializar la queryKey con <code>hashKey</code>, y en ese proceso se ignora el orden de las claves de los objetos mientras se conserva el orden de los elementos de los arreglos.</p>
<p>La queryKey es el arreglo que TanStack Query usa como base para gestionar la caché de consultas. La misma clave significa los mismos datos, y cuando <code>['user', userId]</code> cambia porque cambió su <code>userId</code>, se produce un fallo de caché y los datos se vuelven a obtener con fetch.</p>
<h2 id="el-interior-de-querycache"><a class="anchor" href="#el-interior-de-querycache">El interior de QueryCache</a></h2>
<p>Según <a href="https://tkdodo.eu/blog/inside-react-query" target="_blank" rel="noopener noreferrer">El interior de React Query</a>, de TkDodo, <code>QueryCache</code> no es más que <strong>una estructura de datos mantenida en memoria</strong>. En la <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">implementación oficial</a> de v5 esa estructura no es un objeto plano, sino un <code>Map&#x3C;string, Query></code>. El campo tiene el tipo <code>QueryStore</code>, y el constructor le asigna un <code>new Map&#x3C;string, Query>()</code>. Las entradas se guardan y se consultan con <code>queryHash</code> como clave. La clave es la forma serializada de queryKey (<code>queryHash</code>), y el valor es una instancia de la clase <code>Query</code>. El código y los resultados de este artículo se basan en <code>@tanstack/query-core</code> 5.104.1.</p>
<p>A diferencia de los objetos planos que usaban las versiones antiguas, un <code>Map</code> nunca choca con claves heredadas de un prototipo.</p>
<p>Lo que ocurre cada vez que se llama a <code>useQuery</code> es sencillo: <strong>queryKey se convierte en un valor hash y este se utiliza para buscar en el mapa</strong>. Si existe, se recupera la instancia de <code>Query</code> almacenada en caché; si no, se crea una nueva y se guarda con <code>set</code>.</p>
<p>De aquí se desprende otra pregunta natural: <strong>¿por qué serializar queryKey como una cadena?</strong> ¿No bastaría con usar el propio arreglo como clave, como en <code>Map&#x3C;QueryKey, Query></code>?</p>
<p>La respuesta está en el modelo de igualdad de JavaScript. El <code>Map</code> nativo compara sus claves mediante <strong>igualdad referencial (reference equality)</strong>. Aunque el contenido sea el mismo, considera diferentes dos objetos que ocupan lugares distintos en memoria.</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>Sin embargo, en un componente de React, <code>useQuery({ queryKey: ['user', userId] })</code> <strong>crea una nueva instancia del arreglo en cada renderizado</strong>. Aunque los arreglos queryKey del primer y del segundo renderizado tengan el mismo contenido, son objetos distintos en memoria. Si la caché dependiera de la igualdad referencial, cada renderizado de un componente que mostrase los mismos datos provocaría un fallo de caché.</p>
<p>La solución al problema causado por la igualdad referencial es sencilla: <strong>convertir la igualdad referencial en igualdad estructural (structural equality)</strong>. Se genera una cadena determinista basada únicamente en el contenido de queryKey y se usa esa cadena como clave del mapa. Así se recupera la semántica deseada: «si el contenido es igual, la clave es igual». <code>JSON.stringify</code> no es más que la herramienta más sencilla para realizar esa conversión.</p>
<h2 id="ordenamiento-de-claves-en-hashkey"><a class="anchor" href="#ordenamiento-de-claves-en-hashkey">Ordenamiento de claves en hashKey</a></h2>
<p>La función que genera el valor hash es <code>hashKey</code>. La implementación oficial, definida en <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>, es esta.</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>Usa <code>JSON.stringify</code> con una <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#the_replacer_parameter" target="_blank" rel="noopener noreferrer">función de reemplazo</a> que <strong>ordena lexicográficamente las claves de los objetos planos</strong> antes de serializarlos. En rigor, es el orden de unidades de código UTF-16, la comparación por defecto de <code>sort()</code>, así que las claves en mayúscula van antes que las de minúscula.</p>
<p>Este ordenamiento es esencial porque la serialización a una cadena impone otra condición aún más estricta: <strong>las entradas semánticamente iguales deben convertirse siempre en la misma cadena</strong>. Sin embargo, <code>JSON.stringify</code> normal conserva el orden de las claves. Aunque <code>{ a: 1, b: 2 }</code> y <code>{ b: 2, a: 1 }</code> sean objetos semánticamente iguales, se serializan como cadenas diferentes y terminan ocupando espacios de caché distintos. Así volverían a solicitarse dos veces los mismos datos.</p>
<p>La técnica que evita sistemáticamente este problema es la <strong>forma canónica (canonical form)</strong>. Consiste en obligar a que las entradas semánticamente iguales correspondan siempre a una única representación. Ese es el motivo por el que la función de reemplazo de <code>hashKey</code> ordena las claves de los objetos planos. Hace que el resultado sea idéntico con independencia del orden de entrada, de modo que los objetos con el mismo significado se conviertan siempre en la misma cadena. La dirección contraria no está garantizada, como veremos más adelante.</p>
<p>El hecho de que los arreglos no se ordenen es la otra cara del mismo principio. En un arreglo, el propio orden contiene significado; ordenarlo supondría perder información. El orden de las claves de un objeto es accidental, mientras que el orden de los elementos de un arreglo es intencionado. <code>hashKey</code> trata ambos casos de forma distinta. Que el mantenedor TkDodo recomiende en <a href="https://tkdodo.eu/blog/effective-react-query-keys" target="_blank" rel="noopener noreferrer">Effective React Query Keys</a> estructurar queryKey desde lo más genérico hasta lo más específico también se debe a que el orden del arreglo tiene significado. La razón que él da es la invalidación: las claves que comparten la parte inicial se pueden invalidar todas a la vez con <code>['todos']</code>. Esa comparación no la hace el hash, sino la coincidencia por prefijo que veremos más adelante.</p>
<p>El ordenamiento de claves solo se aplica a los <strong>objetos planos</strong>. <code>isPlainObject</code>, definida en el mismo archivo, comprueba si <code>Object.prototype.toString</code> devuelve <code>[object Object]</code> y si el prototipo es <code>Object.prototype</code> (o <code>null</code>) para distinguir entre <strong>literales de objeto puros</strong> e <strong>instancias de clase</strong>. Por eso un literal como <code>{ foo: 1 }</code> se ordena, mientras que una instancia creada con <code>class User { ... }</code> pasa sin ordenarse. Si se introduce directamente una instancia de clase en queryKey, sus claves no se ordenan y se serializa en el orden en que se asignaron sus campos, así que valores iguales pueden producir hashes distintos.</p>
<p>Visto desde quien lo usa, hay dos resultados.</p>
<h3 id="el-orden-de-las-claves-de-un-objeto-es-irrelevante"><a class="anchor" href="#el-orden-de-las-claves-de-un-objeto-es-irrelevante">El orden de las claves de un objeto es irrelevante</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>Sin el ordenamiento de claves, al escribir un literal de objeto habría que recordar siempre el orden de sus claves.</p>
<h3 id="el-orden-de-los-elementos-de-un-arreglo-sí-importa"><a class="anchor" href="#el-orden-de-los-elementos-de-un-arreglo-sí-importa">El orden de los elementos de un arreglo sí importa</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="valores-que-cambia-la-serialización"><a class="anchor" href="#valores-que-cambia-la-serialización">Valores que cambia la serialización</a></h2>
<p>También conviene saber que los valores <code>undefined</code> desaparecen durante la serialización. <code>{ a: 1, b: undefined }</code> y <code>{ a: 1 }</code> generan el mismo hash. (Yo mismo cometí una vez el error de pensar: «¡Como he añadido undefined de forma explícita, será otra caché!».)</p>
<p>Dentro de un arreglo el comportamiento es distinto. Un elemento <code>undefined</code> no desaparece, sino que se convierte en <code>null</code>. Por eso <code>['user', undefined]</code> y <code>['user', null]</code> son la misma clave, y ambas son distintas de <code>['user']</code>. Es lo que ocurre con <code>['user', userId]</code> cuando <code>userId</code> todavía es <code>undefined</code>. Aunque bloquees el fetch con <code>enabled: false</code>, en la caché se crea igualmente una entrada <code>["user",null]</code>. Lo comprobé creando con las mismas opciones el objeto que <code>useQuery</code> usa internamente, un <code>QueryObserver</code>.</p>
<p><code>undefined</code> no es el único caso. Como <code>hashKey</code> se apoya en <code>JSON.stringify</code>, la mayoría de los valores que JSON no puede representar se convierten en otros valores sin ningún error. Ejecuté el siguiente código el 2026-10-08 con <code>@tanstack/query-core</code> 5.104.1 y Node v24.16.0; los comentarios son la salida real.</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>Si se prueban otros valores de la misma manera, el resultado se resume así.</p>
<table>
<thead>
<tr>
<th>Valor en la clave</th>
<th>Resultado serializado</th>
<th>Se trata como la misma clave que</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>undefined</code>, <code>NaN</code>, <code>Infinity</code> o una función como elemento de arreglo</td>
<td><code>null</code></td>
<td>una clave con <code>null</code> en esa posición</td>
</tr>
<tr>
<td><code>undefined</code> o una función como propiedad de objeto</td>
<td>la propiedad desaparece</td>
<td>una clave sin esa propiedad</td>
</tr>
<tr>
<td><code>Map</code>, <code>Set</code></td>
<td><code>{}</code></td>
<td>cualquier <code>Map</code>, <code>Set</code> u objeto vacío, sea cual sea su contenido</td>
</tr>
<tr>
<td><code>Date</code></td>
<td>una cadena ISO</td>
<td>la misma cadena ISO</td>
</tr>
<tr>
<td><code>BigInt</code></td>
<td>lanza <code>TypeError</code></td>
<td>ninguna</td>
</tr>
<tr>
<td>referencia circular</td>
<td>lanza <code>RangeError</code> si el ciclo pasa solo por objetos planos y <code>TypeError</code> si pasa por un arreglo o una instancia de clase</td>
<td>ninguna</td>
</tr>
</tbody>
</table>
<p>Los más peligrosos son <code>Map</code> y <code>Set</code>. En el código anterior, los datos guardados con la clave <code>new Map([['a', 1]])</code> se recuperaron al consultar con <code>new Map([['b', 2]])</code>. Como no hay error, tampoco hay ninguna pista de que la pantalla está mostrando datos equivocados.</p>
<p>Solo dos casos aparecen como error: <code>BigInt</code> y las referencias circulares. En las referencias circulares, el error depende de qué forma el ciclo. Un ciclo que pasa solo por objetos planos termina con <code>RangeError: Maximum call stack size exceeded</code>, mientras que uno que pasa aunque sea por un arreglo o una instancia de clase, como en <code>arr.push(arr)</code>, termina con <code>TypeError: Converting circular structure to JSON</code>. Como la función de reemplazo devuelve un objeto nuevo por cada objeto plano, la detección de ciclos de <code>JSON.stringify</code> nunca vuelve a encontrar el mismo objeto; los arreglos y las instancias de clase, en cambio, la función de reemplazo los devuelve tal cual, y el ciclo se detecta. Por su parte, <code>Date</code> se convierte en una cadena ISO mediante <code>toJSON</code>, así que en la consulta de la caché el mismo instante produce la misma clave y resulta relativamente seguro.</p>
<p>Una vez puse un <code>Date</code> directamente en una clave y pasé mucho tiempo preguntándome: «¿Por qué se actualiza la caché si es el mismo instante?». Un <code>Date</code> que apunta al mismo instante se convierte en la misma cadena ISO aunque sea otra instancia, así que produce el mismo hash. Si cada vez salía una clave distinta, el instante en sí era distinto, aunque pareciera el mismo. Si se crea un <code>new Date()</code> durante el renderizado, cada renderizado recibe una hora distinta por milisegundos, y cada una se convierte en una clave nueva.</p>
<p>Por eso lo más seguro es poner en una queryKey solo cadenas, números, booleanos, <code>null</code> y arreglos u objetos planos formados por ellos.</p>
<h2 id="querykeyhashfn"><a class="anchor" href="#querykeyhashfn">queryKeyHashFn</a></h2>
<p>Esta restricción tiene una vía de escape. Mediante la opción <code>queryKeyHashFn</code>, TanStack Query permite <strong>sustituir la propia función hash</strong>. Internamente, <code>hashQueryKeyByOptions(queryKey, options)</code> decide: si las opciones incluyen <code>queryKeyHashFn</code>, la llama; si no, llama a la función predeterminada <code>hashKey</code>.</p>
<p>Sustituirla significa reemplazar <code>hashKey</code> por completo. El ordenamiento de claves visto antes desaparece con ella, así que si lo necesitas tienes que implementarlo tú. El caso en que esta opción resulta útil es un valor con el que la serialización por defecto lanza un error, como <code>BigInt</code>. El siguiente código también lo ejecuté en el mismo entorno.</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>La segunda salida significa que un <code>BigInt</code> y una cadena con el mismo número se convierten en la misma clave. La última salida es el efecto de perder el ordenamiento: los objetos que solo difieren en el orden de sus claves ya no se tratan como la misma clave.</p>
<p>El lugar donde se registra también cambia el resultado. Si se registra en <code>QueryClient</code> mediante <code>defaultOptions</code> o con <code>setQueryDefaults</code>, como arriba, <code>setQueryData</code> y <code>getQueryData</code> también usan esa función. Ocurre porque ambas API combinan las opciones por defecto con <code>defaultQueryOptions</code> antes de calcular el hash. En cambio, si se escribe solo en la llamada a <code>useQuery</code>, las API imperativas usan el <code>hashKey</code> predeterminado y la misma clave se divide en dos entradas dentro de la caché. En la etapa beta de v3.2.0, ni siquiera el valor global se aplicaba a <code>setQueryData</code>, y quien reportó el <a href="https://github.com/TanStack/query/issues/1343" target="_blank" rel="noopener noreferrer">Issue #1343</a> confirmó que quedó corregido en v3.2.0-beta.30.</p>
<p>Por eso, en producción es mucho más seguro evitar la vía de escape y <strong>convertir los valores a una forma serializable al construir la queryKey</strong>. Escribir tu propia función hash obliga a ocuparse tanto del ordenamiento de claves como del lugar donde se registra.</p>
<h2 id="cómo-comparan-claves-los-filtros"><a class="anchor" href="#cómo-comparan-claves-los-filtros">Cómo comparan claves los filtros</a></h2>
<p>Hay una forma más de juzgar si dos claves son iguales. La «misma clave» de la que se ha hablado hasta ahora significa que las cadenas hash son iguales, y el hash solo se usa en la búsqueda en caché y en los filtros con <code>exact: true</code>. Los filtros como <code>invalidateQueries</code> o <code>findAll</code> deciden por defecto con <code>partialMatchKey</code>, que compara de forma recursiva la estructura de la queryKey original, no la cadena hash. Los arreglos se comparan desde el principio y en los objetos solo se miran las claves escritas en el filtro. El siguiente código también lo ejecuté en el mismo entorno.</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>Como esta coincidencia no pasa por el hash, sustituir <code>queryKeyHashFn</code> no la cambia. En un <code>QueryClient</code> con una función hash sin ordenamiento, como en el ejemplo anterior, un objeto que solo difiere en el orden de sus claves no se encuentra con <code>exact: true</code>, pero la coincidencia por prefijo sí lo encuentra.</p>
<p>Por eso no hay garantía de que las claves iguales por hash también lo sean en un filtro. Una consulta creada con <code>['user', undefined]</code> no queda atrapada por la coincidencia por prefijo de un filtro <code>['user', null]</code>, y una clave que contiene <code>NaN</code> ni siquiera coincide consigo misma. A la inversa, si se pone un <code>Date</code> o un <code>Map</code> en un filtro, como no tiene propiedades enumerables que comparar, coincide con cualquier <code>Date</code> u objeto en la misma posición.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En resumen, TanStack Query no compara las referencias de los arreglos queryKey. <code>hashKey</code> ordena las claves de los objetos planos mientras serializa con <code>JSON.stringify</code>, y la cadena resultante (<code>queryHash</code>) se usa como clave de un <code>Map</code>. Por eso el orden de las claves de un objeto no afecta a la caché, el orden de los elementos de un arreglo sí, y una propiedad cuyo valor es <code>undefined</code> equivale, para el hash, a una propiedad ausente. La mayoría de los valores que JSON no puede representar se convierten en otros sin ningún error, de modo que claves distintas pasan a ser la misma en silencio. Se puede cambiar la función hash con <code>queryKeyHashFn</code>, pero eso también descarta el ordenamiento de claves, así que es más seguro convertir los valores en serializables al construir la clave. Los filtros, como los de invalidación, por defecto no pasan por el hash y comparan la estructura de la queryKey desde el principio, así que conviene no esperar que las claves iguales por hash lo sean también en un filtro. Al final, la búsqueda en caché compara el contenido de la clave mediante <code>hashKey</code> y los filtros comparan su parte inicial, así que si en queryKey solo se ponen valores simples cuyo significado no cambia al serializarse, ambas comparaciones se comportan como se espera.</p>
<p>Cómo este criterio se traslada a la forma de escribir y gestionar las queryKey, es decir, el camino desde los arreglos en línea, pasando por las fábricas de claves, hasta <code>queryOptions</code>, se trata en <a href="/260104" target="_blank" rel="noopener noreferrer">queryKey</a>.</p>
<p>La próxima vez que metas un objeto o un <code>Map</code> en una queryKey, espero que pienses por un momento en qué cadena se va a serializar.</p>
<details class="ref-block"><summary class="ref-summary">참고 자료</summary><div class="ref-list">
<div class="ref-item">
<div class="ref-item-inner">[documentación] <a href="https://tanstack.com/query/latest/docs/framework/react/guides/query-keys" target="_blank" rel="noopener noreferrer">TanStack Query, claves de consulta</a></div>
</div>
<div class="ref-item">
<div class="ref-item-inner">[documentación] <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[Dónde colocar ErrorBoundary]]></title>
            <link>https://hooninedev.com/es/251203</link>
            <guid isPermaLink="false">https://hooninedev.com/es/251203</guid>
            <pubDate>Wed, 03 Dec 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta entrada quiero hablar de cuántos ErrorBoundary tener y dónde ponerlos. Está pensada para desarrolladores frontend que construyen pantallas con React Router y TanStack Query y no terminan de de...]]></description>
            <content:encoded><![CDATA[<p>En esta entrada quiero hablar de <strong>cuántos <code>ErrorBoundary</code> tener y dónde ponerlos</strong>. Está pensada para desarrolladores frontend que construyen pantallas con React Router y TanStack Query y no terminan de decidir si un fallo debe recibirlo el <code>ErrorBoundary</code> de la ruta, <code>react-error-boundary</code> o <code>useQuery</code> con su <code>isError</code>. Al terminar, tendrás un criterio para decidir dónde va cada <code>ErrorBoundary</code> y una forma de ajustar el fallback y la condición de retry a cada sitio.</p>
<p>Por adelantar la conclusión, el número de <code>ErrorBoundary</code> no lo decide el gusto sino dos cosas: <strong>qué lanza</strong> y <strong>qué tiene que quedar en pantalla cuando eso muere</strong>. Esta entrada empieza por lo segundo y trata lo primero donde haga falta.</p>
<h2 id="el-alcance-que-puedes-permitirte"><a class="anchor" href="#el-alcance-que-puedes-permitirte">El alcance que puedes permitirte</a></h2>
<p>Cuando alguien pregunta dónde poner un <code>ErrorBoundary</code>, suele pensar en qué componente envolver. Esa pregunta no tiene respuesta. Siempre hay varios componentes que podrías envolver, y el código funciona elijas el que elijas.</p>
<p>Hay que preguntarlo de otro modo. <strong>Si esto muere, ¿qué tiene que quedar en pantalla?</strong></p>
<p>La respuesta cambia según el sitio. Si falta el dato que forma el esqueleto de una pantalla, esa pantalla no se sostiene. Un botón de acción solo, sin nombre ni estado al lado, no significa nada. Al revés, si falla una lista secundaria y tapas la pantalla entera, el usuario pierde todo lo demás que podía ver perfectamente. Las acciones difíciles de deshacer son otra cosa. Hay que avisar del fallo en el mismo sitio donde se pulsó.</p>
<p><strong>Lo que hace un <code>ErrorBoundary</code> no es atrapar el fallo, sino decidir el área sobre la que se dibuja el fallback.</strong> El fallback es lo que dibujas en lugar de la pantalla original cuando algo falla. Desaparece todo lo que envolviste. Por eso la posición de un <code>ErrorBoundary</code> no la decide lo que quieres atrapar, sino <strong>el alcance que puedes permitirte</strong>.</p>
<p>Ese alcance se divide en cuatro niveles.</p>
<table>
<thead>
<tr>
<th>Capa</th>
<th>Qué recibe</th>
<th>Con qué se deshace</th>
</tr>
</thead>
<tbody>
<tr>
<td>Ruta</td>
<td>lo que lanzó el loader, lo que nadie atrapó más abajo</td>
<td><code>revalidate()</code></td>
</tr>
<tr>
<td>Pantalla</td>
<td>lo lanzado durante el render</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>Región</td>
<td>lo lanzado dentro de ella</td>
<td><code>resetErrorBoundary()</code></td>
</tr>
<tr>
<td>Dentro del componente</td>
<td>un <code>useQuery</code> fallido, una mutation fallida</td>
<td><code>refetch()</code>, un 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="Rectángulos anidados: un ErrorBoundary de ruta contiene un ErrorBoundary de pantalla, y este contiene un ErrorBoundary de región y una caja de dentro del componente, una al lado de la otra. Desde la izquierda entran flechas: lo que lanzó el loader va al ErrorBoundary de ruta, lo lanzado durante el render va al ErrorBoundary de pantalla, el fallo de esa región va al ErrorBoundary de región, y lo que no lanza va a la caja de dentro del componente. Una flecha que sale hacia arriba de la caja de dentro del componente está tachada en rojo y marcada como que no sube" style="max-width:min(720px, 100%);height:auto;" width="1800" height="1000" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>Son cuatro nombres pero solo dos tipos. <strong>Solo el <code>ErrorBoundary</code> de ruta es del router; los otros dos son el <code>ErrorBoundary</code> que colocas en el árbol.</strong> El <code>ErrorBoundary</code> de pantalla y el <code>ErrorBoundary</code> de región son el mismo componente y solo cambia dónde está enganchado. La fila de abajo, dentro del componente, no es un <code>ErrorBoundary</code> sino una rama que el propio componente dibuja.</p>
<p>Las secciones siguientes ven por qué no puedes borrar ninguna fila de esta tabla.</p>
<h2 id="tres-capas-que-no-puedes-borrar"><a class="anchor" href="#tres-capas-que-no-puedes-borrar">Tres capas que no puedes borrar</a></h2>
<p>Cuando instalas <code>react-error-boundary</code>, el lado del router parece borrable. Como los nombres coinciden, los trabajos también lo parecen. Pero hay tres razones por las que no puedes borrar ninguno. Las tres que cuento aquí son ruta, pantalla y región de la tabla de arriba. Dentro del componente queda fuera porque no es un <code>ErrorBoundary</code>.</p>
<h3 id="el-loader-vive-fuera-de-errorboundary"><a class="anchor" href="#el-loader-vive-fuera-de-errorboundary">El loader vive fuera de ErrorBoundary</a></h3>
<p>Un <code>ErrorBoundary</code> es una clase construida con <code>getDerivedStateFromError</code> y <code>componentDidCatch</code>, así que solo le llega <strong>lo que React atrapó dentro del árbol</strong>. El loader es una función que corre fuera del árbol, antes de que empiece el render. Lo que lanza ahí no pasa por React, así que por mucho que lo envuelvas no se ve. Cómo comprobé los destinos lanzando el mismo error desde varios sitios está recogido en <a href="/251117" target="_blank" rel="noopener noreferrer">Propagación de errores</a>.</p>
<p>Por eso, si aunque sea una ruta usa loader, <strong>no puedes borrar el <code>ErrorBoundary</code> de ruta.</strong> En cuanto lo borras, ese fallo se queda sin sitio adonde ir.</p>
<h3 id="la-caché-que-revalidate-no-puede-limpiar"><a class="anchor" href="#la-caché-que-revalidate-no-puede-limpiar">La caché que revalidate no puede limpiar</a></h3>
<p>La dirección contraria también está cerrada. El mecanismo de recuperación del <code>ErrorBoundary</code> de ruta es <code>revalidate()</code>, y eso solo vuelve a ejecutar el loader: no toca la caché de queries.</p>
<p>Piensa en una ruta sin loader. Si un <code>useSuspenseQuery</code> dentro de la pantalla falla, al final también lo recibe el <code>ErrorBoundary</code> de ruta. El router envuelve el árbol de rutas en una clase propia llamada <code>RenderErrorBoundary</code>, y esa clase también tiene <code>getDerivedStateFromError</code>. Pero pulsar reintentar en ese <code>ErrorBoundary</code> no sirve de nada: <strong>no hay loader que volver a ejecutar y el error clavado en la caché sigue ahí.</strong> Lo recibió, pero no tiene con qué deshacerlo.</p>
<p><strong>Recibir y deshacer son trabajos distintos.</strong> Si no miras los dos al colocar un <code>ErrorBoundary</code>, acabas con un fallback que recibe cosas que nadie puede limpiar.</p>
<h3 id="el-errorboundary-de-abajo-estrecha-el-alcance"><a class="anchor" href="#el-errorboundary-de-abajo-estrecha-el-alcance">El ErrorBoundary de abajo estrecha el alcance</a></h3>
<p>El tercero no es quedarse bloqueado, sino perder demasiado.</p>
<p>Cuando algo falla, el router elige un <code>ErrorBoundary</code>. Cómo lo elige está en el código.</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>Recorre las rutas emparejadas desde el final, elige la primera que tenga <code>ErrorBoundary</code> y, si no hay ninguna, lo manda a la primera de todas. Si el hijo de una ruta anidada no tiene <code>ErrorBoundary</code>, ese papel lo asume <strong>el padre</strong>, y si el padre tampoco lo tiene, lo asume la raíz.</p>
<p>Cuando lo asume la raíz, la pantalla entera desaparece. Solo falló una región interior, pero se van con ella la cabecera y la navegación. Engancha un <code>ErrorBoundary</code> a la ruta hija y el fallback se dibuja solo en el hueco de <code>&#x3C;Outlet /></code>.</p>
<p><strong>Por eso poner un <code>ErrorBoundary</code> más abajo no es duplicar.</strong> No atrapa el mismo fallo dos veces: <strong>cambia hasta dónde se borra</strong>. Una disposición que parece tres límites solapados es en realidad tres cosas distintas recibidas con tres alcances distintos.</p>
<h2 id="entre-el-errorboundary-de-región-y-dentro-del-componente"><a class="anchor" href="#entre-el-errorboundary-de-región-y-dentro-del-componente">Entre el ErrorBoundary de región y dentro del componente</a></h2>
<p>Una vez dividida la cosa en cuatro capas, queda una última bifurcación. Para una región sin la que la pantalla se sostiene, eliges entre <strong>subirla a un <code>ErrorBoundary</code> o recibirla ahí mismo</strong>.</p>
<p>De cualquiera de las dos maneras pierdes solo esa región y conservas el resto. Lo que cambia no es cuánto pierdes, sino <strong>qué dibujas ahí en su lugar</strong>.</p>
<p>Subirlo a un <code>ErrorBoundary</code> reduce el código. Si llamas a <code>useSuspenseQuery</code> dentro, ese componente no tiene ni <code>isPending</code> ni <code>isError</code>. La espera la recibe el <code>Suspense</code> de fuera y el fallo, el <code>ErrorBoundary</code> de fuera. El componente solo dibuja el caso en que hay datos.</p>
<p>El <code>QueryAsyncBoundary</code> de abajo es un componente que junta ese <code>Suspense</code> y ese <code>ErrorBoundary</code>. Dentro de <code>QueryErrorResetBoundary</code> pone un <code>ErrorBoundary</code>, conecta su <code>onReset</code> a <code>reset</code> y envuelve el interior en <code>Suspense</code>. Ponerle <code>Query</code> en el nombre es intencionado. Si lo llamas <code>AsyncBoundary</code> se lee como si sirviera para cualquier asincronía, y no es así, porque dentro lleva <code>QueryErrorResetBoundary</code>. Por la misma razón no le puse valor por defecto a <code>pendingFallback</code>. Con un valor por defecto, mirando solo la línea de la llamada no sabes qué se está poniendo debajo.</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>A cambio, esa región entera se convierte en el fallback. Si es un sitio como una lista, donde no queda nada que salvar si se va entera, no pierdes nada.</p>
<p>Recibirlo ahí mismo es lo contrario. Llamas a <code>useQuery</code> y dibujas tú los cuatro estados (espera, fallo, resultado vacío, resultado). Se multiplican las ramas, pero a cambio puedes poner <strong>textos y acciones ajustados a ese sitio</strong>. Si quieres un botón pequeño de reintentar solo en la fila que falló, es por aquí. Si lo subes a un <code>ErrorBoundary</code>, la forma de esa fila la decide el componente de fallback, que suele ser el compartido con el <code>ErrorBoundary</code> de pantalla y resulta excesivo para el fallo de una sola fila.</p>
<p>El criterio queda así. <strong>Si la región puede desaparecer entera, mejor <code>ErrorBoundary</code>; si hacen falta textos o acciones ajustados a ese sitio, mejor <code>useQuery</code>.</strong> El <code>ErrorBoundary</code> te quita las ramas del componente, pero se lleva con ellas la libertad de decidir la pantalla.</p>
<p>Si decides no subirlo a un <code>ErrorBoundary</code>, <strong>tienes que tratar los cuatro estados.</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>Si te dejas <code>isError</code>, el fallo se cuela en silencio hacia el estado vacío. El <code>data</code> de una query fallida es <code>undefined</code>, y una comprobación como <code>data == null || data.length === 0</code> no distingue ese <code>undefined</code> de un array vacío. En pantalla sale que todavía no hay comentarios mientras el servidor está devolviendo un 500 en ese mismo momento.</p>
<p>Si filtras <code>isError</code> primero, más abajo <code>data</code> se estrecha a un array y desaparece la comprobación <code>== null</code>.</p>
<h2 id="un-fallback-distinto-por-capa"><a class="anchor" href="#un-fallback-distinto-por-capa">Un fallback distinto por capa</a></h2>
<p>Colocadas las capas, toca decidir qué dibuja cada una en pantalla. Aquí vuelven a separarse el <code>ErrorBoundary</code> de ruta y el <code>ErrorBoundary</code> que colocas en el árbol, y <strong>el criterio para elegir no es el gusto, sino cómo recibe el error esa capa</strong>.</p>
<h3 id="el-errorboundary-de-ruta-lo-lee-solo"><a class="anchor" href="#el-errorboundary-de-ruta-lo-lee-solo">El ErrorBoundary de ruta lo lee solo</a></h3>
<p>Al <code>ErrorBoundary</code> de ruta no hace falta pasarle el error al fallback. El componente lo lee directamente con <code>useRouteError()</code> y también consigue el mecanismo de recuperación por su cuenta.</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>Así que a la ruta solo hay que enchufarle el componente. El <code>ErrorBoundary</code> de una ruta hija tiene la misma forma y usa los mismos hooks. Lo que los separa no es el código sino <strong>la ruta a la que están enganchados</strong>. En qué ruta se enganchó es exactamente el alcance sobre el que se dibuja el fallback.</p>
<h3 id="el-errorboundary-que-lo-pasa-hacia-abajo"><a class="anchor" href="#el-errorboundary-que-lo-pasa-hacia-abajo">El ErrorBoundary que lo pasa hacia abajo</a></h3>
<p>En <code>react-error-boundary</code>, el <code>ErrorBoundary</code> es lo contrario. Como tiene él mismo el error y la función de reset, tiene que pasárselos al fallback. Por eso hay tres props, y la definición de tipos ata las tres como mutuamente excluyentes.</p>
<p><strong><code>fallback</code></strong> recibe un elemento ya terminado tal cual. Se escribe así: <code>fallback={&#x3C;p role="alert">댓글을 불러오지 못했어요&#x3C;/p>}</code>. No te da ni el error ni la función de reset. Solo sirve cuando el mensaje es fijo y tampoco hay forma de reintentar.</p>
<p><strong><code>FallbackComponent</code></strong> recibe un componente y le pasa <code>error</code> y <code>resetErrorBoundary</code> como props. Queda limpio cuando varios <code>ErrorBoundary</code> comparten el mismo fallback, pero el nombre de la prop está fijado como <code>resetErrorBoundary</code>, así que quien lo recibe tiene que conocer ese nombre.</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>El <code>reset</code> del código es la función que pasa el <code>QueryErrorResetBoundary</code> de TanStack Query. Limpia las queries que quedaron en estado de error para que el reintento vuelva a pedir; por qué hace falta lo sigo en el código en <a href="/251128" target="_blank" rel="noopener noreferrer">Por qué el botón de reintentar no hace nada</a>.</p>
<p><code>ErrorFallback</code> recibe <code>onRetry</code>, así que los nombres no encajan. Eso cuesta un componente más cuyo único trabajo es mover el nombre de un lado a otro.</p>
<p><strong><code>fallbackRender</code></strong> recibe una función y la renderiza ahí mismo.</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>Yo elegí este. La razón es <strong>que puedes cambiar el nombre ahí mismo</strong>. Arriba, <code>resetErrorBoundary</code> pasó a ser <code>onRetry</code>. Gracias a eso, <code>ErrorFallback</code> se convierte en un componente que solo conoce <code>error</code> y <code>onRetry</code>, y <code>revalidate</code> como mecanismo del <code>ErrorBoundary</code> de ruta y <code>resetErrorBoundary</code> como mecanismo del <code>ErrorBoundary</code> del árbol: los dos <strong>usan el mismo fallback.</strong></p>
<p><strong>Que las pantallas de dos capas no se desalineen es lo que compra esta elección.</strong> Si divides en cuatro capas, las pantallas de fallo que ve el usuario corren el riesgo de ser también cuatro, y cambiar un nombre una vez las convierte en una.</p>
<h2 id="fallos-a-los-que-no-poner-reintentar"><a class="anchor" href="#fallos-a-los-que-no-poner-reintentar">Fallos a los que no poner reintentar</a></h2>
<p>Poner un botón de reintentar en el fallback no resuelve todos los fallos, porque un <code>ErrorBoundary</code> solo restablece su propio estado. El error de una query solo se vuelve a pedir cuando <code>QueryErrorResetBoundary</code> conecta su <code>reset</code> a <code>onReset</code>; el error de render que surge al leer un valor inesperado que el servidor envió con un 200 solo se resuelve si <code>queryFn</code> lo comprueba antes y lo convierte en el error de la query; y el fallo al cargar un chunk de <code>React.lazy</code> solo se resuelve recargando. Cómo comprobé los tres casos en el código lo recojo aparte en «Por qué el botón de reintentar no hace nada», enlazado más arriba. Como los fallos se dividen así entre recuperables y no recuperables, hay que separar también el botón.</p>
<p>Enseñar el mismo botón en todos los fallos es <strong>guiar al usuario hacia una acción que no puede hacer</strong>. Si pulsas reintentar en un 404 vuelve el mismo 404. Un 403 por falta de permisos es igual. El fallo al cargar un chunk directamente no hace nada, como vimos arriba.</p>
<p>Basta con separarlo una vez dentro del fallback compartido.</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>Hay una razón para que <code>isRouteErrorResponse</code> esté aquí también. Como el <code>ErrorBoundary</code> de ruta y el <code>ErrorBoundary</code> del árbol comparten fallback, esta función <strong>recibe los dos tipos de error.</strong> Leer el código de estado de un <code>Response</code> que lanzó el router y leerlo de un error HTTP que construiste tú funcionan de manera distinta, así que mira los dos.</p>
<p>No hace falta montar una ramificación enorme desde el principio. Basta con separar <strong>los fallos que dan otro resultado al volver a pulsar de los que no</strong>.</p>
<h2 id="cuándo-ve-el-fallo-el-errorboundary"><a class="anchor" href="#cuándo-ve-el-fallo-el-errorboundary">Cuándo ve el fallo el ErrorBoundary</a></h2>
<p>Colocados todos los <code>ErrorBoundary</code>, todavía queda uno. <strong>¿Cuándo mira el <code>ErrorBoundary</code>?</strong></p>
<p>Las capas se decidieron de arriba abajo, pero el orden de ejecución es el contrario. El <code>ErrorBoundary</code> no ve nada hasta que se han agotado todos los reintentos. Por eso la condición de reintento es <strong>parte del diseño del <code>ErrorBoundary</code></strong>.</p>
<p>El valor por defecto cambia según el sitio. Los dos usan el mismo <code>createRetryer</code>, pero le pasan valores distintos. La query no pasa nada, así que recibe el valor por defecto de <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>La mutation mete un 0 directamente en <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>Que la mutation esté a 0 significa que, en cuanto una petición acaba en error, esa acción falla de inmediato.</strong> Que las acciones difíciles de deshacer no tengan reintentos es un valor por defecto seguro, pero si la acción es idempotente, mandarla una vez más es mejor para el usuario.</p>
<p>Es mejor declarar la misma condición en los dos lados.</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> empieza en 0 y <strong>en el primer fallo pregunta con 0.</strong> Así que con <code>MAX_RETRY</code> en 2 manda tres peticiones en total y para. Es el número de reintentos, no el número de intentos.</p>
<p><strong>Dejar fuera los 4xx es lo esencial.</strong> La petición en sí está mal, así que por muchas veces que la mandes vuelve la misma respuesta. Pero la razón para dejarlos fuera no es solo el desperdicio: el retardo por defecto crece de forma exponencial.</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>El primer reintento es 1 segundo y el siguiente 2, así que con solo dos reintentos se van 3 segundos. Si no filtras los 4xx, estás <strong>escondiendo durante 3 segundos un fallo que no tiene arreglo</strong>.</p>
<p>Los errores de render y los fallos de chunk no tienen ese tiempo. Como no sale ninguna petición, no hay reintento alguno y el <code>ErrorBoundary</code> lo ve en el momento en que se lanza. <strong>Por eso el mismo fallback sale 3 segundos después en unos fallos y al instante en otros.</strong></p>
<p>Hay una premisa que comprobar antes de activar los reintentos. ¿Esa petición es <strong>idempotente?</strong> Mandar la misma petición dos veces tiene que dar el mismo resultado. Si el servidor no lo garantiza, reintentar es una función que fabrica bugs.</p>
<h2 id="para-terminar"><a class="anchor" href="#para-terminar">Para terminar</a></h2>
<p>En esta entrada decidí qué poner en cada sitio que recibe un fallo. Resumiendo:</p>
<ul>
<li>La posición de un <code>ErrorBoundary</code> la decide el alcance que puedes permitirte. No preguntas qué envolver, sino qué tiene que quedar cuando esto muere.</li>
<li>No puedes borrar ninguna de las tres capas de <code>ErrorBoundary</code>. Lo que lanzó el loader no lo recibe el <code>ErrorBoundary</code> del árbol, <code>revalidate</code> no limpia la caché de queries, y poner uno más abajo es estrechar el alcance.</li>
<li>Si la región se puede tirar entera, <code>ErrorBoundary</code>; si hacen falta textos propios de ese sitio, <code>useQuery</code>. Si eliges lo segundo, tienes que tratar los cuatro estados.</li>
<li>Si cambias el nombre con <code>fallbackRender</code>, el fallback queda unificado aunque las capas sean distintas.</li>
<li>Reintentar solo funciona si limpias el estado que tiene quien lanzó. La query es <code>reset</code>, el error de render se sube a <code>queryFn</code>, y el chunk es una recarga.</li>
<li>Cuándo ve el fallo el <code>ErrorBoundary</code> lo decide la condición de reintento. Si no filtras los 4xx, escondes unos segundos un fallo sin arreglo.</li>
</ul>
<p>Se puede preguntar si no basta con un <code>ErrorBoundary</code> y un toast. Para un producto de dos o tres pantallas suena razonable y, de hecho, <strong>el número de capas lo decide el producto.</strong> Si solo hay una pantalla, solo hay una pantalla que ceder, así que las diferencias de alcance no se ven. Cuantas más regiones independientes haya dentro de una pantalla, mayor se hace esa diferencia.</p>
<p>Aun así, hay algo que queda al margen del número. Pongas los <code>ErrorBoundary</code> que pongas en el árbol, lo que lanzó <code>loader</code> no va allí, y reintentar no funciona si no limpias el estado que tiene quien lanzó. <strong>Lo que puedes reducir son las capas, no estos hechos.</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>manejo-de-errores</category>
        </item>
        <item>
            <title><![CDATA[Por qué el botón de reintentar no hace nada]]></title>
            <link>https://hooninedev.com/es/251128</link>
            <guid isPermaLink="false">https://hooninedev.com/es/251128</guid>
            <pubDate>Fri, 28 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta entrada quiero hablar de por qué el botón de reintentar de un ErrorBoundary no hace nada. Está pensada para desarrolladores frontend que pusieron un botón de reintentar en el fallback de react...]]></description>
            <content:encoded><![CDATA[<p>En esta entrada quiero hablar de <strong>por qué el botón de reintentar de un <code>ErrorBoundary</code> no hace nada</strong>.</p>
<p>Está pensada para desarrolladores frontend que pusieron un botón de reintentar en el fallback de <code>react-error-boundary</code> y, al pulsarlo, vuelven a ver la misma pantalla, y recoge los tres casos en que el estado del fallo se queda y cómo resolver cada uno. La respuesta corta: un <code>ErrorBoundary</code> solo restablece su propio estado, y el estado que provocó el fallo se queda en quien lanzó.</p>
<p>Los ejemplos usan TanStack Query junto con <code>react-error-boundary</code>. Comprobé el comportamiento de las librerías abriendo su código instalado, y el código citado tiene la misma estructura que los archivos compilados de <code>@tanstack/react-query</code> 5.104.1, <code>react-error-boundary</code> 6.1.6 y React 19.2.3 (cotejado el 2026-10-08). La sangría y los saltos de línea se ajustaron en algunos puntos para facilitar la lectura.</p>
<h2 id="tres-casos-en-los-que-reintentar-no-hace-nada"><a class="anchor" href="#tres-casos-en-los-que-reintentar-no-hace-nada">Tres casos en los que reintentar no hace nada</a></h2>
<p>Hay tres causas, y cada una se resuelve en un sitio distinto. Tienen una cosa en común. <strong>Un <code>ErrorBoundary</code> solo deshace su propio estado.</strong> El estado que tiene quien lanzó hay que limpiarlo en quien lanzó.</p>
<h3 id="el-error-de-query-que-reset-limpia"><a class="anchor" href="#el-error-de-query-que-reset-limpia">El error de query que reset limpia</a></h3>
<p>Lo único que hace <code>resetErrorBoundary()</code> es devolver a su sitio el estado interno del <code>ErrorBoundary</code> (<code>didCatch</code>). Cuando los children se vuelven a montar, el hook de la query vuelve a leer la caché. Pero esa query está <strong>clavada en la caché en estado de error.</strong> Así que el hook lanza el mismo error enseguida, durante el render, y el <code>ErrorBoundary</code> vuelve a dibujar el fallback.</p>
<p>Por qué usa el error viejo en lugar de volver a pedir también está en el código. <code>errorBoundaryUtils.js</code> apaga así la nueva petición al remontar.</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>Hay que leer primero la guarda de fuera. <strong>Esta condición solo afecta a las queries que lanzan.</strong> Si una query con <code>suspense</code>, o con <code>throwOnError</code> activado, se monta mientras <code>isReset()</code> es falso, <code>retryOnMount</code> pasa a <code>false</code> y la nueva petición queda apagada. Un <code>useQuery</code> que no lanza no entra ahí y, al volver a montarse, simplemente vuelve a pedir.</p>
<p>Ahora bien, apagar <code>retryOnMount</code> solo tiene efecto en una query que nunca tuvo datos en la caché, es decir, una query que falló en su primera carga. Una query que ya tenía datos, al fallar, queda con <code>isInvalidated</code> en verdadero, se trata como stale y vuelve a pedir al remontarse gracias a <code>refetchOnMount</code>. Los errores de query de los que habla este artículo son fallos de la primera carga.</p>
<p>Eso no significa que la nueva petición quede apagada para siempre. Mientras se ve el fallback, ningún componente observa esa query, así que pasa a ser una query inactiva y, según <a href="https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults" target="_blank" rel="noopener noreferrer">los valores por defecto de TanStack Query</a>, se borra de la caché a los 5 minutos. Si se pulsa después, no hay error en la caché y vuelve a pedir desde cero. Por eso un código que olvidó <code>onReset</code> puede parecer que funciona si se pulsa mucho más tarde. Si la reproducción es irregular, mira primero si pulsaste antes o después de <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="Arriba, el flujo cuando onReset no está conectado: clic en reintentar, ErrorBoundary liberado, remontaje, vuelve a lanzar el error de la caché, y desde la última casilla una flecha roja vuelve a la primera con la etiqueta el mismo fallback. Abajo, el flujo cuando onReset sí está conectado: clic en reintentar, onReset y la llamada a reset(), ErrorBoundary liberado y remontaje, nueva petición, encadenados en una sola dirección con flechas azules" 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> solo se apaga en la query subida a un <code>ErrorBoundary</code>, y por eso hay que limpiar el <code>ErrorBoundary</code> y la query a la vez.</strong> Quien levanta la bandera que lee <code>isReset()</code> es <code>QueryErrorResetBoundary</code>. Si abres el código, el estado es un solo booleano.</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>Si la bandera solo se levantara y nadie la bajara, los errores posteriores tampoco apagarían <code>retryOnMount</code> y cada uno volvería a pedir. Quien la baja es el hook de la query. El hook remontado lee la bandera durante el render y no apaga <code>retryOnMount</code>. <code>getHasError</code>, que mira la misma bandera, tampoco lanza el error de la caché. Después, ya montado en pantalla, baja la bandera en un effect. Es una función del mismo <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>Así, un solo boolean basta para reintentar solo esta vez. Paso a paso, un reintento ocurre así:</p>
<ol>
<li><code>reset()</code> levanta la bandera <code>isReset</code>.</li>
<li>El hook de la query remontado ve la bandera durante el render y no apaga <code>retryOnMount</code>.</li>
<li>Con la bandera levantada, el hook ve un estado de petición en curso en lugar del error de la caché y vuelve a pedir (<code>useSuspenseQuery</code> envía la petición durante el render y suspende).</li>
<li>Si la petición tiene éxito y el componente queda montado en pantalla, la baja <code>clearReset()</code> en un effect; si vuelve a fallar, la baja el <code>catch</code> de esa petición.</li>
<li>Con la bandera bajada, los errores posteriores vuelven a apagar <code>retryOnMount</code>.</li>
</ol>
<p>Basta con conectar este <code>reset</code> al <code>ErrorBoundary</code>, en su <code>onReset</code>. La documentación de TanStack Query y los comentarios del código también traen como ejemplo código que los conecta así.</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>Así implementa <code>react-error-boundary</code> su <code>resetErrorBoundary</code>. Es un archivo compilado, así que los nombres se han quedado en una letra, pero la estructura se lee igual.</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>Solo cuando <code>didCatch</code> es verdadero llama a <code>onReset</code> y devuelve el estado al valor inicial <code>d</code> (<code>didCatch: false</code>). <code>setState</code> se renderiza cuando termina el handler, así que cuando los children se vuelven a montar, la bandera que levantó <code>reset()</code> ya está arriba. Por eso basta con conectar <code>onReset</code> a <code>reset</code>.</p>
<p>Aunque no envuelvas con <code>QueryErrorResetBoundary</code>, si tomas de <code>useQueryErrorResetBoundary()</code> su <code>reset</code> y lo conectas a <code>onReset</code>, el reintento funciona. Sin un límite que lo envuelva, el hook devuelve un valor por defecto global del módulo. A cambio, como indica la <a href="https://tanstack.com/query/latest/docs/framework/react/guides/suspense" target="_blank" rel="noopener noreferrer">guía de Suspense</a>, el reset se aplica globalmente y toda la aplicación comparte una sola bandera <code>isReset</code>.</p>
<h3 id="el-error-de-render-que-reset-no-puede-limpiar"><a class="anchor" href="#el-error-de-render-que-reset-no-puede-limpiar">El error de render que reset no puede limpiar</a></h3>
<p>El segundo es cuando el servidor devuelve un 200 con una forma distinta de la esperada y el render que la lee lanza un <code>TypeError</code>. Lo recibió el mismo <code>ErrorBoundary</code> y <code>onReset</code> está conectado, pero reintentar no hace nada.</p>
<p>Lo que <code>reset</code> limpia es <strong>una query en estado de error</strong>. Pero esta query tuvo éxito. El servidor dio un 200 y la caché guarda ese valor como dato normal. Quien produjo el error fue el render que leyó ese valor. Así que <code>reset</code> no tiene nada que limpiar, y el componente remontado lee el mismo valor de la caché durante el render y vuelve a lanzar en la misma línea. Como lanza antes de montarse en pantalla, ni siquiera llega a suscribirse a la query. Por eso no vuelve a pedir aunque <code>staleTime</code> sea 0. Esta query también se borra de la caché cuando pasa <code>gcTime</code> y se vuelve a pedir, pero mientras el servidor envíe el mismo valor, vuelve a lanzar en la misma línea.</p>
<p>El sitio donde se arregla es <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>En una respuesta de <code>fetch</code>, <code>json()</code> devuelve <code>Promise&#x3C;any></code>, así que el tipo que pongas después es una declaración, no una comprobación, y la comprobación en tiempo de ejecución que verifica lo que envió el servidor tienes que ponerla tú. <strong>Si subes esa comprobación a <code>queryFn</code></strong>, el mismo fallo se convierte en <strong>el error de la query</strong>. Se queda en la caché en estado de error, <code>reset</code> lo limpia y el reintento vuelve a pedir.</p>
<p>Si aplazas la comprobación en tiempo de ejecución porque total ya la recibe el <code>ErrorBoundary</code>, acabas con un fallback que recibe pero no puede deshacer.</p>
<h3 id="el-lazy-que-solo-arregla-una-recarga"><a class="anchor" href="#el-lazy-que-solo-arregla-una-recarga">El lazy que solo arregla una recarga</a></h3>
<p>El tercero es un fallo al cargar un chunk. Esta vez no tienen nada que ver ni <code>reset</code> ni <code>queryFn</code>. Quien guarda el estado es el propio <code>lazy</code>.</p>
<p>El <code>lazyInitializer</code> de React apunta el rechazo en <code>payload</code> y a partir de ahí lanza siempre lo mismo.</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>No vuelve a hacer <code>import()</code>. La llamada a <code>lazy()</code> ocurrió una vez en el nivel superior del módulo y ese <code>payload</code> se queda igual mientras viva la aplicación. Aunque sueltes el <code>ErrorBoundary</code> y se vuelva a montar, vuelve el mismo error.</p>
<p>¿Y si se crea un <code>lazy</code> nuevo y se vuelve a llamar a <code>import()</code>? Hasta ahora lo impedía el navegador. El mapa de módulos recordaba el resultado fallido y no volvía a descargar la misma URL. Un <a href="https://github.com/whatwg/html/pull/10327" target="_blank" rel="noopener noreferrer">cambio en la especificación HTML</a> que altera esto se fusionó el 2026-07-15. El estado por motor, comprobado el 2026-10-08: Firefox <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=2055211" target="_blank" rel="noopener noreferrer">lo incluyó en 155</a>, publicado el 2026-09-01. WebKit <a href="https://bugs.webkit.org/show_bug.cgi?id=319492" target="_blank" rel="noopener noreferrer">lo integró en main</a> el 2026-08-19, y el registro del bug no indica qué versión estable de Safari lo incluye. Chrome sigue en Proposed en <a href="https://chromestatus.com/feature/5214647044145152" target="_blank" rel="noopener noreferrer">chromestatus</a>. Así que en Chrome un <code>import()</code> nuevo devuelve el mismo fallo.</p>
<p>Esto vale para las compilaciones que cargan los chunks con el <code>import()</code> nativo del navegador, como Vite. El runtime de webpack carga los chunks con etiquetas script y borra el registro del chunk que falló, así que, si se vuelve a llamar con un <code>lazy</code> nuevo, vuelve a pedirlo.</p>
<p>En cualquier caso, un <code>lazy</code> ya creado no lo vuelve a intentar, así que la recuperación por defecto de este fallo es volver a descargar la página. Aunque los navegadores lleguen a volver a descargar, no todo se resuelve. Un fallo de carga de chunk puede venir de una red caída o, como explica la <a href="https://vite.dev/guide/build#load-error-handling" target="_blank" rel="noopener noreferrer">documentación de Vite</a>, de un despliegue nuevo que borró los chunks antiguos. Un chunk borrado sigue sin existir al pedirlo otra vez, así que en ese caso la recuperación sigue siendo recargar. Como no se puede fijar una sola causa, es mejor que el texto del fallback sugiera recargar en lugar de afirmar que salió una versión nueva.</p>
<p>En resumen, el error de query se resuelve con <code>reset</code>, el error de render convirtiéndolo antes en error de query dentro de <code>queryFn</code>, y el fallo de chunk recargando.</p>
<h2 id="para-terminar"><a class="anchor" href="#para-terminar">Para terminar</a></h2>
<p>Si un botón de reintentar no hace nada, ojalá compruebes <strong>qué lanzó y dónde sigue guardado su estado</strong> antes de mirar el botón o el <code>ErrorBoundary</code>. Dónde se resuelve depende de si es la caché de la query, el render que leyó lo que envió el servidor o <code>lazy</code>.</p>
<p>Dónde colocar <code>ErrorBoundary</code> en la pantalla y cuántos, y qué fallos no deberían llevar botón de reintentar, lo trato en <a href="/251203" target="_blank" rel="noopener noreferrer">Dónde colocar 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>manejo-de-errores</category>
        </item>
        <item>
            <title><![CDATA[Propagación de errores]]></title>
            <link>https://hooninedev.com/es/251117</link>
            <guid isPermaLink="false">https://hooninedev.com/es/251117</guid>
            <pubDate>Mon, 17 Nov 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de hasta dónde sube un error en el frontend. Dibujar un ErrorBoundary es fácil. Pones uno en la ruta, envuelves cada pantalla en un ErrorBoundary y, sobre el papel, no q...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de <strong>hasta dónde sube un error en el frontend</strong>.</p>
<p>Dibujar un <code>ErrorBoundary</code> es fácil. Pones uno en la ruta, envuelves cada pantalla en un <code>ErrorBoundary</code> y, sobre el papel, no queda ningún hueco. Lo difícil es saber qué recibe <strong>de verdad</strong> ese <code>ErrorBoundary</code>.</p>
<p>La palabra «propagación» del título significa <strong>hasta qué punto sube un error lanzado y quién acaba recibiéndolo</strong>. El sitio donde se lanza y el sitio donde se recibe no siempre coinciden, y por eso hay que contarlos por separado.</p>
<p>Así que decidí contarlo. Lancé el mismo <code>new Error('boom')</code> siete veces cambiando solo el sitio, y miré si llegaba al <code>ErrorBoundary</code> o si pasaba de largo del <code>ErrorBoundary</code> y acababa en otro lado.</p>
<p>El widget de abajo es ese experimento. Si eliges a la izquierda el sitio desde el que lanzar, el error se lanza de verdad dentro de la caja de línea discontinua. Esa caja es el <code>ErrorBoundary</code>, y cuando el <code>ErrorBoundary</code> lo recibe, su interior se convierte en un fallback. Lo que no se recibe parece que no hubiera pasado nada, así que el widget escucha también en <code>window</code> los eventos <code>error</code> y <code>unhandledrejection</code> y escribe debajo adónde ha ido.</p>
<div class="interactive-widget" data-widget="error-propagation"></div>
<p>De los siete, solo cuatro llegan al <code>ErrorBoundary</code>. Los otros tres se lanzan dentro del <code>ErrorBoundary</code> y aun así atraviesan ese mismo <code>ErrorBoundary</code> y salen al ámbito global.</p>
<p>En este artículo compruebo por qué aparece esa división, tipo de error por tipo de error: los que terminan en tiempo de compilación, los que salen del render y del ciclo de vida, los que vienen de los datos del servidor, los que salen en la navegación, los que lanzan las bibliotecas externas y los que salen de eventos y código asíncrono. La idea es averiguar hacia dónde propaga cada uno. <strong>En cuántas capas dividir el lado que recibe, y cómo deshacer el fallo, no se tratan aquí.</strong> Dibujar capas sin conocer las rutas de propagación deja esas capas vacías, y por eso el orden es este.</p>
<p>Lo comprobé de dos maneras. El comportamiento de las bibliotecas lo leí abriendo las fuentes instaladas en lugar de fiarme de la memoria, y todo lo que podía confirmarse lanzando lo ejecuté de verdad en el widget de arriba.</p>
<h2 id="errorboundary"><a class="anchor" href="#errorboundary">ErrorBoundary</a></h2>
<p>Primero conviene dejar claro qué es un <code>ErrorBoundary</code>. Un <code>ErrorBoundary</code> son <strong>dos métodos del ciclo de vida de un componente de clase</strong>. Solo que los llamamos de otra forma.</p>
<p>En <code>react-error-boundary</code> existen únicamente dos implementaciones.</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>La documentación oficial de React separa el momento en el que se ejecuta cada método. <code>getDerivedStateFromError</code> se ejecuta en la <strong>fase de render</strong> y produce el estado con el que se dibuja el fallback, mientras que <code>componentDidCatch</code> se ejecuta en la <strong>fase de commit</strong> y se encarga de efectos secundarios como el registro. En cualquiera de los dos casos solo reciben <strong>lo que React ha capturado dentro del árbol y les ha entregado</strong>.</p>
<p>Así que la definición de lo que recibe un <code>ErrorBoundary</code> es una sola. <strong>¿Se lanzó desde un sitio que React puede capturar?</strong></p>
<p>La documentación de React también deja escrito el lado que no se recibe. Son estos cuatro.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Manejadores de eventos, renderizado en el servidor, errores lanzados por el propio <code>ErrorBoundary</code> en lugar de por sus hijos, código asíncrono (por ejemplo, callbacks de <code>setTimeout</code> o de <code>requestAnimationFrame</code>); hay una excepción: el Hook <code>useTransition</code> y la función <code>startTransition</code> que devuelve. Los errores lanzados dentro de esa función de transición sí los capturan los 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>El resultado del widget encaja exactamente con esa frase. Siete sitios se reparten en dos destinos.</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="A la izquierda hay siete sitios de lanzamiento en vertical y a la derecha dos destinos. Durante el render, dentro de useEffect, dentro de startTransition y el rechazo del import de lazy van con flechas azules hasta ErrorBoundary, mientras que dentro de un manejador onClick, dentro de un callback de setTimeout y el rechazo de una Promise van con flechas grises hasta window" style="max-width:min(720px, 100%);height:auto;" width="1760" height="940" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><code>startTransition</code> es la excepción porque el trabajo que hay dentro pasa por el planificador de React. React envuelve esa ejecución con sus propias manos, así que puede capturarla y devolverla al árbol. Por la misma razón <code>setTimeout</code> no se puede capturar: cuando ese callback se ejecuta, React ya no está ahí.</p>
<p>Antes hay que saber una cosa. <strong>Con un componente de función no se puede construir un <code>ErrorBoundary</code>.</strong></p>
<p>Cuando React se encuentra con un error lanzado, sube desde ese punto hacia el padre buscando el límite que lo reciba. <code>throwException</code>, que se encarga de ese recorrido, mira el <code>tag</code> que lleva cada fiber, y los únicos valores en los que se detiene son componente de clase y raíz. El <code>tag</code> de un componente de función no es ninguno de los dos, así que la búsqueda pasa de largo. <strong>No llega a ser un límite no porque falte la API, sino porque la búsqueda ni siquiera lo mira.</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="Cuatro cajas en horizontal. Desde la izquierda, los componentes de función Child y FnBoundary tienen tag 0, el componente de clase ErrorBoundary tiene tag 1 y la raíz HostRoot tiene tag 3. Una flecha que sale de Child pasa por FnBoundary y se detiene en ErrorBoundary, y la línea que continúa hasta HostRoot es discontinua" style="max-width:min(720px, 100%);height:auto;" width="2016" height="424" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Ese <code>tag</code> lo decide si el componente hereda de <code>React.Component</code>. Por eso no sirve de nada colgar <code>getDerivedStateFromError</code> de una función como <code>static</code>. Lo colgué y lo ejecuté: esa función no se llamó ni una sola vez, el error no encontró límite, subió hasta la raíz y React retiró el árbol de la pantalla.</p>
<p>Así que instalar <code>react-error-boundary</code> no es añadir una funcionalidad que no existe. El <code>ErrorBoundary</code> de la 6.1.6 también es una clase que hereda de <code>Component</code> y lleva los mismos dos métodos. Lo que hace la biblioteca es escribir esa clase una sola vez y esconderla.</p>
<h2 id="lo-que-termina-en-tiempo-de-compilación"><a class="anchor" href="#lo-que-termina-en-tiempo-de-compilación">Lo que termina en tiempo de compilación</a></h2>
<p>Ya que estamos contando tipos de error, empecemos por el primero de todos: los errores de tipos.</p>
<p>Es el único que no llega al usuario. Si lees una propiedad que no existe o el tipo de un argumento no encaja, la compilación se detiene, y el código detenido no se despliega. Por eso no hay ninguna ruta de propagación que seguir. <strong>Los errores de tipos quedan fuera de este artículo no porque no importen, sino porque no existen en tiempo de ejecución.</strong></p>
<p>El problema viene después. ¿<strong>Hasta dónde</strong> garantiza la comprobación de tipos?</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>El tipo de retorno dice <code>Promise&#x3C;Comment[]></code>, así que todo el código que llama a esta función confía en recibir un array. Pero <code>Response</code> declara su <code>json()</code> así en el <code>lib.dom.d.ts</code> de TypeScript 5.9.3.</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>. Aquí es donde se corta la comprobación de tipos. El compilador nunca ha visto el valor que ha devuelto el servidor, y el argumento de tipo o la anotación de retorno que pongas después son <strong>una declaración, no una comprobación</strong>. Nadie añade por ti el código que verifica esa declaración en tiempo de ejecución.</p>
<p>Así que cuando el servidor responde <code>200</code> con <code>{ commits: null }</code>, la línea que lee <code>commits.length</code> lanza un <code>TypeError</code> durante el render. El HTTP fue un éxito y los tipos pasaron, y aun así la pantalla se rompe.</p>
<p><strong>Donde terminan los tipos es donde toca poner una comprobación en tiempo de ejecución.</strong> Dónde coloques esa comprobación decide por qué ruta va el mismo fallo. Si revienta al leer durante el render se convierte en un error de render y va al <code>ErrorBoundary</code>; si compruebas antes, en el sitio donde llegan los datos, y lanzas ahí, se convierte en el fallo de esa petición. La sección <strong>Render y ciclo de vida</strong> de más abajo trata eso.</p>
<h2 id="render-y-ciclo-de-vida"><a class="anchor" href="#render-y-ciclo-de-vida">Render y ciclo de vida</a></h2>
<p>Lo que se lanza dentro del árbol de React propaga de forma sencilla. <strong>Lo recibe el <code>ErrorBoundary</code> más cercano.</strong></p>
<p>Aquí entran las excepciones durante el render. El <code>commits.length</code> de antes es una, y llamar a <code>map</code> sobre un valor que creías que era un array es otra. Este grupo no puede hacer otra cosa que lanzar, así que siempre llega a un <code>ErrorBoundary</code>.</p>
<p>Lo que se lanza dentro de <code>useEffect</code> también se captura. React ejecuta los effects por su cuenta después del commit, así que puede envolver esa ejecución. Pero un <strong>callback asíncrono llamado dentro</strong> de un effect es distinto.</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>Los dos fragmentos están dentro del mismo <code>useEffect</code> y aun así sus rutas de propagación son distintas. <strong>El criterio no es si está dentro de un <code>ErrorBoundary</code>, sino si React está sujetando esa ejecución.</strong></p>
<p>De este grupo basta con recordar una cosa más. La consola de desarrollo de React 19 le pone el nombre del componente al error capturado. Al ejecutar el widget, el render, el effect y la transición daban <code>The above error occurred in the &#x3C;Thrower> component</code>, y solo <code>lazy</code> daba <code>occurred in one of your React components</code>. <code>lazy</code> todavía no tiene componente en el momento en que se rechaza, así que no puede escribir un nombre. Esa diferencia es una pista cuando solo tienes la pila para localizar el sitio.</p>
<h2 id="datos-del-servidor"><a class="anchor" href="#datos-del-servidor">Datos del servidor</a></h2>
<p>Aquí empieza la parte incómoda que hay que manejar cuando desarrollas frontend. La razón es que un fallo del servidor <strong>no se convierte en error automáticamente</strong>.</p>
<h3 id="fetch-no-rechaza-por-sí-solo-ante-un-error-del-servidor"><a class="anchor" href="#fetch-no-rechaza-por-sí-solo-ante-un-error-del-servidor">fetch no rechaza por sí solo ante un error del servidor</a></h3>
<p>MDN lo deja escrito con claridad.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>La promesa de <code>fetch()</code> solo se rechaza cuando falla la petición en sí, por ejemplo porque la URL está mal formada o porque ha habido un error de red. No se rechaza cuando el servidor responde con un código de estado HTTP que indica error (<code>404</code>, <code>504</code>, etc.).</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>Es decir, si usas solo <code>fetch</code>, una respuesta <code>500</code> es una <strong>Promise con éxito</strong>. Como no se ha lanzado nada, el <code>ErrorBoundary</code> no se entera y la biblioteca de datos tampoco. La documentación de TanStack Query también señala esto: para que una consulta se considere fallida, el <code>queryFn</code> tiene que lanzar o devolver una Promise rechazada, y mientras <code>axios</code> lanza por su cuenta, <code>fetch</code> no lo hace.</p>
<p>Así que convertir el fallo del servidor en un error es <strong>algo que tienes que hacer tú</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>Sin estas tres líneas, el resto de esta sección no significa nada. Lo que no se lanza no propaga a ninguna parte.</p>
<h3 id="los-cinco-caminos-por-los-que-se-reparte-un-fallo"><a class="anchor" href="#los-cinco-caminos-por-los-que-se-reparte-un-fallo">Los cinco caminos por los que se reparte un fallo</a></h3>
<p>Una vez que el fallo se ha convertido en fallo, empieza la siguiente división. Un mismo <code>500</code> se dispersa hacia cinco sitios según <strong>cómo lo hayas llamado</strong>. El criterio son los valores por defecto, sin tocar ninguna opción.</p>
<p><strong><code>useQuery</code> no lanza el error.</strong> Si abres <code>useQuery.js</code>, la cadena <code>throwOnError</code> no aparece por ningún lado. Si se lanza o no lo decide <code>query-core</code> con su <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>Si no hay valor es <code>!!undefined</code>, así que <code>false</code>. Por eso el fallo entra solo en <code>query.error</code> y el componente se renderiza con normalidad. El <code>ErrorBoundary</code> de fuera no llega a enterarse de que le tocaba.</p>
<p><strong><code>useSuspenseQuery</code> lanza, pero no siempre.</strong> Este hook despliega las opciones y después sobrescribe <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>La sobrescritura viene después de <code>...options</code>, así que <strong>el <code>throwOnError</code> que pase quien llama se ignora.</strong> Y la decisión por defecto que ocupa ese sitio está en una sola línea de <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>Si hay caché que mostrar, no lanza.</strong> En la práctica el sitio donde se separa esta rama es una recarga en segundo plano. Quien entra por primera vez tiene la caché vacía, así que el fallo va al <code>ErrorBoundary</code> y ve el fallback. Quien ya estaba en la pantalla, se va a otra pestaña y vuelve, dispara una recarga, y si esa recarga falla la caché todavía guarda los datos viejos, así que no se lanza nada. La pantalla sigue mostrando el valor rancio y no cambia por sí sola.</p>
<p>Que la pantalla no se rompa suele ser un buen comportamiento. Pero con ello viene <strong>que la pantalla tampoco te avise</strong>, y elegirlo sabiéndolo no es lo mismo que sufrirlo sin saberlo.</p>
<p><strong>Ninguna de las dos funciones que devuelve una mutation va al <code>ErrorBoundary</code>.</strong> Los motivos son distintos. Si abres <code>useMutation.js</code>, uno de los lados se traga el rechazo directamente.</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>Esto es <code>mutate</code>. En el mismo archivo, <code>mutateAsync</code> expone <code>result.mutate</code> tal cual, y ese <code>result.mutate</code> es lo que <code>mutationObserver.js</code> ha puesto ahí con <code>mutate: this.mutate</code>, así que acaba siendo la misma función que envuelve la línea de arriba. <strong>Solo uno de los dos lados pasa por <code>.catch(noop)</code>.</strong> Ese rechazo revienta en el sitio donde se hace <code>await</code>, no se lanza durante el render, así que este lado tampoco tiene nada que ver con el <code>ErrorBoundary</code>.</p>
<p><strong>Eso no significa que una mutation no tenga nunca nada que ver con el <code>ErrorBoundary</code>.</strong> En el cuerpo del hook hay un interruptor más.</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>Si le das <code>throwOnError</code>, esta línea lanza <strong>durante el render</strong>, y entonces sí va al <code>ErrorBoundary</code>. Que las dos funciones devueltas no lleguen al <code>ErrorBoundary</code> y que el hook no lance son dos historias distintas.</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="Desde un único 500 del servidor a la izquierda salen cinco flechas hacia useQuery, useSuspenseQuery, un hook con throwOnError activado, mutate y mutateAsync, y cada una sigue hasta query.error, ErrorBoundary, ErrorBoundary, mutation.error y el catch de quien llama. Solo las dos cajas centrales de ErrorBoundary están unidas por una línea discontinua, marcadas como las dos que recibe ErrorBoundary" style="max-width:min(720px, 100%);height:auto;" width="2080" height="800" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>En resumen, el mismo <code>500</code> tiene cinco destinos: el campo <code>query.error</code>, el campo <code>mutation.error</code>, el <code>catch</code> de quien llama y los dos casos que van al <code>ErrorBoundary</code>. Los dos que van al <code>ErrorBoundary</code> son <code>useSuspenseQuery</code> fallando sin caché y tener <code>throwOnError</code> activado. <strong>El destino lo decide la forma de llamar, no el tipo de fallo.</strong></p>
<h2 id="navegación"><a class="anchor" href="#navegación">Navegación</a></h2>
<p>Los fallos que aparecen al cambiar de pantalla se dividen en dos. El criterio es <strong>si está dentro o fuera del árbol de React</strong>.</p>
<h3 id="loader-se-ejecuta-fuera-del-árbol"><a class="anchor" href="#loader-se-ejecuta-fuera-del-árbol">loader se ejecuta fuera del árbol</a></h3>
<p>El <code>loader</code> del router es una función que se ejecuta antes de que empiece el render. No es un componente de React, así que no le llegan ni <code>getDerivedStateFromError</code> ni <code>componentDidCatch</code>. Por mucho que envuelvas cosas con <code>react-error-boundary</code>, ese <code>ErrorBoundary</code> no puede ver el fallo de un loader.</p>
<p>En su lugar, el router mantiene su propio sistema de <code>ErrorBoundary</code>. La documentación de React Router lo escribe así.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>Los route modules capturan automáticamente los errores de tu código y renderizan el <code>ErrorBoundary</code> más cercano.</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>Cómo se elige el más cercano está en las fuentes. <code>findNearestBoundary</code> elige así.</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>Recorre las rutas coincidentes desde atrás, elige la primera que tenga un <code>ErrorBoundary</code> y, si no hay ninguna, lo manda a la ruta de más arriba. <strong>Por eso poner un <code>ErrorBoundary</code> más en una ruta inferior no es duplicar trabajo, sino estrechar el área sobre la que se dibuja el fallback.</strong></p>
<p>El lado que lo lee también es distinto. Un <code>ErrorBoundary</code> de ruta no recibe el error por props, lo saca directamente con <code>useRouteError()</code>. Si lleva o no un código de estado se distingue con <code>isRouteErrorResponse(error)</code>. Estas dos herramientas no existen en el <code>ErrorBoundary</code> de React.</p>
<h3 id="el-rechazo-de-lazy-está-dentro-del-árbol"><a class="anchor" href="#el-rechazo-de-lazy-está-dentro-del-árbol">El rechazo de lazy está dentro del árbol</a></h3>
<p>Dentro de la misma navegación, el lado que recibe el código tarde funciona al revés. Cuando <code>lazy(() => import('./Tab'))</code> ve rechazado su <code>import()</code>, React lo recoge y lo lanza al <strong><code>ErrorBoundary</code> más cercano</strong>. El cuarto botón del widget es esta ruta.</p>
<p>Cuando sale un despliegue, los archivos de chunk viejos desaparecen, pero una pantalla que se abrió antes del despliegue sigue llevando las direcciones viejas. Si en ese estado pide ese código, el <code>import()</code> se rechaza y Chrome lanza <code>TypeError: Failed to fetch dynamically imported module</code>.</p>
<p>Aquí se añade una cosa más. <strong><code>lazy</code> recuerda el rechazo.</strong> El <code>lazyInitializer</code> de React apunta el resultado en el <code>payload</code>, y si se rechaza cambia el estado y guarda el motivo.</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>A partir de ahí, cada vez que se renderiza este componente se ejecuta la última rama.</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>No vuelve a hacer <code>import()</code>. La llamada a <code>lazy()</code> ocurrió una sola vez en el nivel superior del módulo y ese <code>payload</code> se queda ahí mientras viva la aplicación. <strong>Aunque deshagas el <code>ErrorBoundary</code> y vuelvas a montar, llega otra vez el mismo error.</strong> Por eso lo único que recupera este fallo es volver a cargar la página.</p>
<p>Dentro de la misma navegación, el fallo del loader lo recibe el router y el fallo de <code>lazy</code> lo recibe React. <strong>Si no separas estos dos antes de decidir dónde poner un <code>ErrorBoundary</code>, uno de ellos se queda sin sitio al que ir.</strong></p>
<h2 id="manejadores-globales-fuera-de-errorboundary"><a class="anchor" href="#manejadores-globales-fuera-de-errorboundary">Manejadores globales fuera de ErrorBoundary</a></h2>
<p>¿Adónde han ido los tres que el <code>ErrorBoundary</code> no ha capturado? A <strong>los manejadores globales del navegador</strong>.</p>
<p>Lo que se lanza desde un manejador de eventos o desde un callback de <code>setTimeout</code> acaba en <code>window</code>, en su evento <code>error</code>. El rechazo de una Promise va a otro evento. La definición de MDN es esta.</p>
<blockquote class="bilingual-quote"><div class="quote-translation" lang="ko"><p>El evento <code>unhandledrejection</code> se envía al ámbito global de un script cuando se rechaza una Promise de JavaScript que no tiene manejador de rechazo; normalmente es el <code>window</code>, pero también puede ser un <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>Estos dos son el último sitio en el que recibe el navegador. Es también donde la mayoría de las herramientas de monitorización capturan los errores del navegador.</p>
<p>React 19 añadió un sitio más para recibir del lado del árbol. Es una opción de <code>createRoot</code>. La documentación oficial separa los tres así.</p>
<table>
<thead>
<tr>
<th>Opción</th>
<th>Cuándo se llama</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>onCaughtError</code></td>
<td>Cuando React ha capturado un error dentro de un Error Boundary</td>
</tr>
<tr>
<td><code>onUncaughtError</code></td>
<td>Cuando se ha lanzado un error y ningún Error Boundary lo ha capturado</td>
</tr>
<tr>
<td><code>onRecoverableError</code></td>
<td>Cuando React se ha recuperado por sí mismo</td>
</tr>
</tbody>
</table>
<p>Recibir y arreglar son cosas distintas. <strong>Que un manejador global lo capture no restaura la pantalla.</strong> Aunque un error lanzado dentro de <code>onClick</code> lo reciba <code>window</code> y se envíe a la herramienta de monitorización, en ese momento el usuario solo ve un botón que no ha respondido. Informar y recuperar son trabajos distintos.</p>
<h2 id="para-terminar"><a class="anchor" href="#para-terminar">Para terminar</a></h2>
<p>Memorizar el manejo de errores en el frontend como una lista de herramientas deja huecos una y otra vez. Pasa incluso sabiéndose <code>ErrorBoundary</code>, <code>throwOnError</code>, <code>useRouteError</code>, <code>lazy</code> y <code>unhandledrejection</code>. No es por no conocer las herramientas, sino porque <strong>nunca se ha contado qué va adónde</strong>.</p>
<p>Lo que he tratado en este artículo se resume así.</p>
<ul>
<li>Un <code>ErrorBoundary</code> solo recibe lo que React ha capturado y le ha entregado. Los manejadores de eventos y los callbacks asíncronos no están en ese sitio.</li>
<li>La comprobación de tipos termina en la respuesta. Desde el punto en el que <code>json()</code> devuelve <code>any</code>, es una declaración y no una comprobación.</li>
<li>Un fallo del servidor no se convierte en error por sí solo. <code>fetch</code> no rechaza con <code>500</code>, así que lanzar es un trabajo que haces tú.</li>
<li>Una vez lanzado, la forma de llamar decide el destino. El mismo fallo va a un campo, a quien llama o a un <code>ErrorBoundary</code>.</li>
<li>La navegación se divide en dos. El loader está fuera del árbol y lo recibe el router; <code>lazy</code> está dentro del árbol y lo recibe React.</li>
<li>Fuera del <code>ErrorBoundary</code> están los manejadores globales. Capturan, pero la pantalla no se restaura.</li>
</ul>
<p>Así que lo que hay que hacer antes de dibujar un <code>ErrorBoundary</code> no es elegir el componente que se envuelve. Es <strong>escribir todos los sitios en los que esta pantalla puede fallar y marcar a cuál de los seis anteriores corresponde cada uno</strong>. El sitio sin marcar es justamente el hueco.</p>
<p>Estaría bien que miraras cuántos sitios puede fallar ahora mismo en tu pantalla, cuántos de ellos llegan al <code>ErrorBoundary</code> y adónde están yendo los que no llegan.</p>
<p>Con qué recibir cada destino lo trato en <a href="/251203" target="_blank" rel="noopener noreferrer">Dónde colocar ErrorBoundary</a>: en cuántas capas dividir y cómo tratar el área de pantalla que se lleva un fallo. Qué hay que soltar a la vez para que el botón de reintentar del fallback reintente de verdad lo trato aparte en <a href="/251128" target="_blank" rel="noopener noreferrer">Por qué el botón de reintentar no hace nada</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, el Error Boundary de 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, los callbacks de error de 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>manejo-de-errores</category>
        </item>
        <item>
            <title><![CDATA[React Fiber al completo]]></title>
            <link>https://hooninedev.com/es/250520</link>
            <guid isPermaLink="false">https://hooninedev.com/es/250520</guid>
            <pubDate>Tue, 20 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de la arquitectura Fiber, que podría considerarse el corazón de React. Está pensada para desarrolladores frontend que conocen la definición de una línea, «divide el trab...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de la <strong>arquitectura Fiber</strong>, que podría considerarse el corazón de React.</p>
<p>Está pensada para desarrolladores frontend que conocen la definición de una línea, «divide el trabajo en unidades», pero a quienes les cuesta explicar cómo lo implementa React por dentro. Al terminar, podrás explicar, con el código fuente de React como base, por qué apareció Fiber, cómo es un nodo Fiber y cómo el doble búfer, los Lanes y el Work Loop encajan para hacer posibles las Concurrent Features.</p>
<p>Cuando conocí React, la palabra <strong>"Fiber"</strong> me sonaba poco más que a una pregunta habitual de entrevista. Memorizar una definición de una línea —«dividir el renderizado en unidades de trabajo y procesarlas por separado»— me parecía suficiente. Sin embargo, al empezar a examinar el código fuente de React, comprendí que Fiber no era un simple concepto, sino una arquitectura de runtime que gobierna <strong>todo</strong> el renderizado de React.</p>
<h2 id="por-qué-apareció-fiber"><a class="anchor" href="#por-qué-apareció-fiber">¿Por qué apareció Fiber?</a></h2>
<p>Para responder a esta pregunta, primero hay que entender qué problemas tenía el mundo anterior a Fiber: el <strong>Stack Reconciler</strong> utilizado hasta React 15.</p>
<p>Como indica su nombre, Stack Reconciler era un motor de reconciliación basado en llamadas <strong>recursivas (recursive)</strong>. Recorría el árbol de componentes de arriba abajo de forma recursiva y, una vez iniciado el renderizado, no podía detenerse hasta procesar el árbol completo. Era como estar en una llamada telefónica que no puedes cortar hasta que la otra persona termine de hablar. (Imagina que empieza a contarte sus problemas durante tres horas y no puedes interrumpirla. Terrible).</p>
<p>En concreto, Stack Reconciler presentaba las siguientes limitaciones.</p>
<ul>
<li><strong>Imposibilidad de interrumpir el renderizado</strong>: al tener que procesar todo el árbol de una vez, en interfaces complejas el hilo principal quedaba ocupado durante decenas o cientos de milisegundos</li>
<li><strong>Ausencia de prioridades</strong>: tanto si el usuario hacía clic en un botón como si se actualizaban datos en segundo plano, todas las actualizaciones se procesaban de la misma manera</li>
<li><strong>Dificultad para responder a animaciones y gestos</strong>: mantener 60 fps exige completar todo el trabajo de cada frame en unos 16 ms, algo que el renderizado recursivo no podía garantizar</li>
<li><strong>Detención de toda la aplicación ante un error</strong>: si se producía un error en algún punto del árbol de componentes, toda la aplicación se detenía</li>
</ul>
<p>Para superar estas limitaciones, el equipo de React ideó un nuevo modelo de ejecución capaz de <strong>dividir</strong> el trabajo, <strong>asignarle prioridades</strong> y, cuando fuera necesario, <strong>interrumpirlo y reanudarlo</strong>. El resultado fue <strong>React Fiber</strong>.</p>
<p>El documento <a href="https://github.com/acdlite/react-fiber-architecture" target="_blank" rel="noopener noreferrer">react-fiber-architecture</a>, escrito por Andrew Clark, recoge las ideas centrales de este diseño y es la referencia más importante para entender Fiber. (Parece que se incorporó al equipo de React poco después de escribirlo).</p>
<h2 id="stack-vs-fiber"><a class="anchor" href="#stack-vs-fiber">Stack vs Fiber</a></h2>
<p>Entonces, ¿en qué se diferencian Stack Reconciler y Fiber Reconciler a nivel de código?</p>
<h3 id="stack-reconciler-basado-en-recursión"><a class="anchor" href="#stack-reconciler-basado-en-recursión">Stack Reconciler basado en recursión</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>Con el enfoque Stack, al encontrar un componente hijo se entra <strong>inmediatamente en una llamada recursiva</strong>. El problema es que depende directamente del call stack de JavaScript. A medida que aumenta la profundidad de la recursión, se acumulan frames en el call stack y, hasta que todos se resuelven, el hilo principal del navegador no puede hacer ninguna otra cosa.</p>
<p>Dicho de forma sencilla, el navegador queda <strong>completamente inmovilizado</strong> hasta que se vacía el call stack.</p>
<video width="640" height="480" controls>
  <source src="/content/250520/stack.mov" type="video/mp4">
</video>
<p>En el vídeo se observa cómo el hilo principal queda totalmente bloqueado mientras Stack Reconciler renderiza.</p>
<h3 id="fiber-reconciler-basado-en-iteración"><a class="anchor" href="#fiber-reconciler-basado-en-iteración">Fiber Reconciler basado en iteración</a></h3>
<p>Fiber sustituyó la recursión por un <strong>bucle iterativo (iterative loop)</strong>. En lugar de usar el call stack, implementó su propia <strong>pila virtual</strong> en memoria. Cada nodo Fiber actúa como un «frame de la pila» y, dado que estos nodos existen como objetos de JavaScript en la memoria heap, el trabajo puede interrumpirse en cualquier momento y continuar más tarde.</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>Este código muestra el modelo conceptual inicial de Fiber. La clave consiste en procesar una sola unidad de trabajo (unit of work) cada vez dentro del bucle <code>while</code> y, si queda poco tiempo, salir del bucle para devolver el control al navegador.</p>
<p>Ese ejemplo, sin embargo, usa <code>requestIdleCallback</code> solo para ilustrar el concepto; React no lo usa en la práctica. Esta API solo se llama en los periodos de inactividad que decide el navegador, así que, a mi juicio, en una página cuyo hilo principal está ocupado el trabajo de React puede seguir posponiéndose. Por eso el paquete Scheduler de React programa la siguiente macrotask con <code>MessageChannel</code> para continuar el trabajo y, entre una y otra, devuelve el control al hilo principal. Por qué descartó <code>setTimeout</code> en favor de <code>MessageChannel</code> lo explico aparte en <a href="/250515" target="_blank" rel="noopener noreferrer">Por qué React usa MessageChannel</a>.</p>
<video width="640" height="480" controls>
  <source src="/content/250520/fiber.mov" type="video/mp4">
</video>
<p>Con Fiber se puede responder de inmediato a eventos del usuario —clics, escritura, etc.— incluso durante el renderizado. Al ejecutar el trabajo en fragmentos pequeños, el navegador tiene margen para respirar.</p>
<p>Si quieres experimentar directamente la diferencia entre ambos enfoques, haz clic <strong><a href="https://animated-lollipop-2b6cbb.netlify.app/" target="_blank" rel="noopener noreferrer">aquí</a></strong>. Podrás observar con tus propios ojos cómo se comportan Stack Reconciler y Fiber Reconciler.</p>
<p>Estos son precisamente los objetivos fundamentales de Fiber que Andrew Clark destacó en su documento.</p>
<ul>
<li><strong>Poder pausar el trabajo y retomarlo más tarde</strong></li>
<li><strong>Asignar prioridades a distintos tipos de trabajo</strong></li>
<li><strong>Reutilizar trabajo completado anteriormente</strong></li>
<li><strong>Abortar trabajo que ya no sea necesario</strong></li>
</ul>
<h2 id="estructura-interna-de-un-nodo-fiber"><a class="anchor" href="#estructura-interna-de-un-nodo-fiber">Estructura interna de un nodo Fiber</a></h2>
<p>Al llegar hasta aquí surge una pregunta natural: «Entonces, ¿cómo es un nodo Fiber por dentro?».</p>
<p>El equipo de React no ofrece documentación oficial específica sobre la implementación interna de Fiber. Sin embargo, podemos comprender su estructura mediante el documento react-fiber-architecture de Andrew Clark y el código fuente real de React (<code>ReactFiber.js</code>).</p>
<p>Me gusta comparar un nodo Fiber con una <strong>orden de trabajo (Work Order)</strong>. Cuando se ensambla un producto en una fábrica, cada orden indica «qué tipo de pieza es», «qué materiales utiliza», «qué trabajo hay que realizar después» y «qué prioridad tiene». Un nodo Fiber funciona del mismo modo.</p>
<h3 id="reactelement-y-fibernode"><a class="anchor" href="#reactelement-y-fibernode">ReactElement y FiberNode</a></h3>
<p>Para entender Fiber, primero hay que distinguir entre <strong>ReactElement</strong> y <strong>FiberNode</strong>. Aunque suelen confundirse, son entidades completamente diferentes.</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>ReactElement no es más que el <strong>plano</strong> de la UI. Es una solicitud que dice «renderiza este componente con estas props»; no contiene la lógica real de renderizado ni el estado.</p>
<p>En cambio, <strong>FiberNode</strong> es la <strong>unidad de trabajo de runtime</strong> que React crea internamente a partir de ese plano. Aquí aparecen campos que ReactElement no tiene, como <code>tag</code>, <code>stateNode</code>, <code>child/sibling/return</code>, <code>memoizedState</code>, <code>updateQueue</code> y <code>lanes</code>.</p>
<p>Cuando React examina el <code>type</code> de un ReactElement para crear un FiberNode, determina el valor de <strong>tag</strong>.</p>
<ul>
<li>Si <code>type</code> es una función y tiene <code>prototype.isReactComponent</code> → <code>tag = ClassComponent(1)</code></li>
<li>Si <code>type</code> es una función → <code>tag = FunctionComponent(0)</code></li>
<li>Si <code>type</code> es una cadena (<code>"div"</code>, etc.) → <code>tag = HostComponent(5)</code></li>
</ul>
<p><strong>tag</strong> es una constante numérica que representa el tipo de FiberNode. Está definida en <code>ReactWorkTags.js</code> y existen más de 25 tags, entre ellos <code>FunctionComponent(0)</code>, <code>ClassComponent(1)</code>, <code>HostRoot(3)</code>, <code>HostComponent(5)</code> y <code>HostText(6)</code>. React utiliza este valor tag para decidir qué lógica ejecutar en <code>beginWork</code>.</p>
<p><strong>type</strong> desempeña un papel esencial durante la reconciliación (reconciliation). Al comparar el Fiber del renderizado anterior con el nuevo elemento, es <strong>lo primero que React comprueba</strong>. (Este valor se transfiere sin cambios del ReactElement al FiberNode).</p>
<ul>
<li>Si antes era un <code>div</code> y ahora también es un <code>div</code>, React <strong>reutiliza</strong> ese nodo Fiber y solo actualiza sus props</li>
<li>Si antes era un <code>div</code> y ahora ha cambiado a un <code>span</code>, React <strong>descarta</strong> el Fiber anterior y crea uno nuevo</li>
</ul>
<p><strong>key</strong> también se transfiere del ReactElement al FiberNode y se utiliza principalmente al renderizar listas (arrays). Sin key, cuando cambia el orden de los elementos de una lista, React no puede saber con precisión qué elemento se ha movido y adónde. Esto puede provocar operaciones innecesarias sobre el DOM o hacer que el estado interno de un componente se conserve o se pierda de forma involuntaria.</p>
<h3 id="child-sibling-return"><a class="anchor" href="#child-sibling-return">child, sibling, return</a></h3>
<p>Aquí reside el secreto que permite a React Fiber utilizar iteración en lugar de recursión.</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> apunta al <strong>primer</strong> elemento hijo devuelto por el render del componente. En el ejemplo anterior, corresponde a <code>&#x3C;자식1/></code>. <strong>sibling</strong> representa el <strong>siguiente hermano</strong> que comparte el mismo padre. El sibling de <code>&#x3C;자식1/></code> es <code>&#x3C;자식2/></code>. <strong>return</strong> apunta al Fiber padre <strong>al que hay que volver</strong> cuando termina el procesamiento del nodo Fiber actual. Tanto <code>&#x3C;자식1/></code> como <code>&#x3C;자식2/></code> tienen a <code>부모</code> como return.</p>
<p>La estructura creada por estos tres campos es un <strong>árbol en forma de lista simplemente enlazada (Singly Linked List)</strong>. En un árbol convencional resulta intuitivo almacenar un array de hijos (<code>children[]</code>), pero Fiber lo evita deliberadamente.</p>
<p>¿Por qué? Una estructura de hijos basada en arrays exige gestionar un índice durante el recorrido y llevar un seguimiento adicional de «hasta dónde se había procesado» al interrumpirlo y reanudarlo. En cambio, con una linked list basta recordar la referencia al nodo actual para continuar el recorrido en cualquier momento. Esta es la base estructural que permite a Fiber admitir de manera natural la <strong>interrupción y reanudación</strong>.</p>
<p>React recorre los nodos en orden de búsqueda en profundidad (DFS) sobre esta estructura. Desciende siguiendo <code>child</code> (beginWork); al alcanzar un nodo hoja comprueba <code>sibling</code>; y, si no hay hermanos, asciende siguiendo <code>return</code> (completeWork).</p>
<h3 id="pendingprops-y-memoizedprops"><a class="anchor" href="#pendingprops-y-memoizedprops">pendingProps y memoizedProps</a></h3>
<p><strong>pendingProps</strong> son las <strong>props nuevas</strong> recibidas cuando el Fiber comienza a procesarse, mientras que <strong>memoizedProps</strong> son las <strong>props anteriores</strong> cuyo procesamiento terminó en el renderizado previo.</p>
<p>Si ambos valores son iguales, React puede concluir que «este componente no ha cambiado» y reutilizar el resultado del renderizado anterior. Este es el mecanismo esencial de la <strong>optimización por bailout</strong>.</p>
<p>De forma similar, <strong>memoizedState</strong> almacena el estado de los hooks de ese Fiber, y <strong>updateQueue</strong> gestiona como una linked list las actualizaciones de estado todavía pendientes —las llamadas a setState—.</p>
<h3 id="statenode"><a class="anchor" href="#statenode">stateNode</a></h3>
<p><strong>stateNode</strong> referencia la <strong>instancia real</strong> a la que apunta el nodo Fiber.</p>
<ul>
<li>Para un <strong>HostComponent</strong> (div, span, etc.): el nodo DOM real</li>
<li>Para un <strong>ClassComponent</strong>: la instancia de la clase</li>
<li>Para un <strong>HostRoot</strong>: el objeto FiberRoot</li>
</ul>
<p>Este campo sirve de puente entre el mundo virtual de Fiber y el DOM real del navegador.</p>
<h2 id="doble-búfer-árbol-current-y-árbol-workinprogress"><a class="anchor" href="#doble-búfer-árbol-current-y-árbol-workinprogress">Doble búfer: árbol current y árbol workInProgress</a></h2>
<p>Un concepto esencial que no puede faltar al estudiar Fiber es el <strong>doble búfer (Double Buffering)</strong>.</p>
<p>Para entenderlo, pensemos en los gráficos de un videojuego. Si se dibujan píxeles directamente sobre la pantalla visible, el usuario puede ver un frame a medio dibujar, un fenómeno de <strong>parpadeo (flicker)</strong>. Para evitarlo, los motores de juegos utilizan <strong>dos búferes</strong>. Dibujan por completo el siguiente frame en uno de ellos y, cuando está terminado, sustituyen de una vez el búfer que se muestra en pantalla.</p>
<p>React Fiber utiliza exactamente la misma estrategia.</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>El <strong>árbol current</strong> es el árbol Fiber reflejado en la pantalla en ese momento: representa el estado de la UI que ve el usuario. El <strong>árbol workInProgress</strong> es el árbol Fiber que se prepara en segundo plano para el siguiente renderizado.</p>
<p>Ambos árboles se referencian entre sí mediante la propiedad <code>alternate</code>. Todos los cambios se realizan en el árbol workInProgress y, cuando el trabajo termina, los árboles se intercambian con una sola línea: <code>root.current = finishedWork</code>. El anterior workInProgress pasa a ser el nuevo current y el anterior current se recicla como workInProgress en el siguiente renderizado.</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>Detengámonos en el punto clave. <code>stateNode</code> —el nodo DOM real— se <strong>comparte</strong> entre current y workInProgress. En vez de crear un objeto Fiber desde cero en cada ocasión, React reutiliza el alternate existente y solo actualiza los campos modificados. Así puede construir el árbol de manera eficiente en cada renderizado sin añadir presión al recolector de basura (GC).</p>
<p>¿Y si no han cambiado las props ni el state? React puede omitir el subárbol completo mediante una <strong>optimización por bailout</strong>. Si el doble búfer de un juego optimiza por frames, el doble búfer de Fiber permite optimizar incluso <strong>por componentes</strong>.</p>
<h2 id="pendingworkpriority--lanes"><a class="anchor" href="#pendingworkpriority--lanes">pendingWorkPriority => Lanes</a></h2>
<p>Entonces, ¿cómo determina Fiber que «este trabajo es más importante»?</p>
<h3 id="limitaciones-de-expirationtime"><a class="anchor" href="#limitaciones-de-expirationtime">Limitaciones de expirationTime</a></h3>
<p>Las primeras versiones de Fiber empleaban una prioridad numérica llamada <code>pendingWorkPriority</code>, que más tarde evolucionó hacia un único número denominado <code>expirationTime</code>. Cuanto más próxima estaba la expiración, mayor era la prioridad, pero este enfoque tenía una limitación fundamental.</p>
<p>Un solo número no permitía una <strong>clasificación flexible</strong> del tipo «esta actualización pertenece al grupo A y aquella al grupo B». Por ejemplo, cuando una entrada del usuario y una actualización Transition ocurrían al mismo tiempo, el sistema basado en expirationTime solo podía clasificarlas comparando rangos (range), por lo que tenía dificultades para procesar selectivamente determinadas actualizaciones.</p>
<h3 id="lane"><a class="anchor" href="#lane">Lane</a></h3>
<p>Para resolver este problema, Andrew Clark introdujo el sistema de <strong>Lanes</strong> en la <a href="https://github.com/facebook/react/pull/18796" target="_blank" rel="noopener noreferrer">PR #18796</a>.</p>
<p>Para entender las Lanes, imaginemos una <strong>autopista</strong>. Una autopista tiene varios carriles (lanes), cada uno con una función distinta. El carril izquierdo sirve para adelantar —trabajo urgente—, otro para la circulación normal y el arcén para emergencias. Cada vehículo —una actualización— se asigna al carril correspondiente a sus características, y el sistema de gestión de la autopista —el scheduler— decide qué carril deja pasar primero.</p>
<p>Las Lanes de React funcionan igual. A cada actualización se le asigna <strong>un bit (lane)</strong> y, mediante operaciones bit a bit, se crean y comparan grupos.</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>El diseño permite almacenar un total de 31 lanes en un entero de 31 bits para aprovechar la optimización <strong>SMI (Small Integer)</strong> del motor V8. V8 procesa los enteros de hasta 31 bits mediante pointer tagging, lo que permite operar directamente con ellos en la pila sin asignarlos en el heap. En las lanes principales, <strong>cuanto más bajo es el bit, mayor es la prioridad</strong>.</p>
<p>Gracias a esta estructura, React puede decidir qué trabajo procesar primero con una sola operación bit a bit. La función <code>getNextLanes()</code> selecciona en <code>pendingLanes</code> el grupo de lanes con mayor prioridad, omite las lanes suspendidas (suspended) y reintenta primero las lanes que ya han recibido datos (pinged), lo que permite una planificación sofisticada.</p>
<p>Además, para evitar la <strong>inanición (starvation)</strong>, cada lane recibe un tiempo de expiración. Sync/InputContinuous se añade a <code>expiredLanes</code> al cabo de 250 ms y Transition al cabo de 5000 ms, lo que fuerza su procesamiento síncrono. Por baja que sea su prioridad, ningún trabajo queda ignorado para siempre. (Si una prioridad baja significara ser ignorado eternamente, ya no sería un sistema de prioridades, sino un sistema de discriminación).</p>
<h2 id="el-output-de-fiber"><a class="anchor" href="#el-output-de-fiber">El output de Fiber</a></h2>
<p>Después de examinar la estructura de Fiber, surge otra pregunta: ¿cómo se convierten estos nodos Fiber en el <strong>DOM real</strong>?</p>
<p>El output contiene la información concreta de los nodos DOM que se puede aplicar al DOM real. Aquí hay una distinción importante.</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>Solo los <strong>host components</strong> —div, span, img, etc.— crean nodos DOM reales. El navegador no sabe qué es <code>&#x3C;아바타/></code>. Como los componentes definidos por el usuario son conceptos abstractos, deben descomponerse en host components para que el navegador pueda entenderlos.</p>
<p>Veamos el proceso con más detalle.</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>La relación entre el árbol Fiber generado por estos componentes y su output es la siguiente.</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>El output se recopila <strong>de abajo arriba</strong>. Primero se crea el DOM en los nodos hoja (host).</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>A continuación, el host component padre recopila el output de sus hijos.</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>Por último, los componentes definidos por el usuario transmiten directamente el output de sus hijos.</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="planificación-de-fiber"><a class="anchor" href="#planificación-de-fiber">Planificación de Fiber</a></h2>
<p>Si el valor principal de Fiber consiste en «poder dividir el trabajo», ¿dónde se lleva a cabo realmente esa división? En el <strong>Work Loop</strong>.</p>
<h3 id="work-loop-el-corazón-del-recorrido-de-fiber"><a class="anchor" href="#work-loop-el-corazón-del-recorrido-de-fiber">Work Loop: el corazón del recorrido de Fiber</a></h3>
<p>El renderizado de React empieza en el Work Loop definido en <code>ReactFiberWorkLoop.js</code>. React utiliza dos Work Loops según la situación.</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>Observa la diferencia entre ambas funciones. <code>workLoopSync</code> se ejecuta <strong>incondicionalmente</strong> hasta que <code>workInProgress</code> pasa a ser <code>null</code>. En cambio, <code>workLoopConcurrentByScheduler</code> pregunta <code>shouldYield()</code> cada vez que procesa un Fiber y sale del bucle cuando el Scheduler responde que debe ceder.</p>
<p>El Scheduler responde comprobando si han pasado <strong>5 ms</strong> desde que empezó la tarea actual. Esos 5 ms no son el tamaño de un fragmento de trabajo sino el umbral para comprobar si hay que ceder, así que si procesar un solo Fiber tarda más, ese trabajo no se divide. El mismo archivo contiene también <code>workLoopConcurrent</code>, que hace que el trabajo non-idle, como Transition, ceda cada 25 ms. Pero esa función está detrás del flag <code>enableThrottledScheduling</code>, y el flag está desactivado en todas las builds de v19.3.0.</p>
<h3 id="performunitofwork"><a class="anchor" href="#performunitofwork">performUnitOfWork</a></h3>
<p><code>performUnitOfWork</code> procesa un nodo Fiber. Esta función contiene el núcleo del recorrido de Fiber.</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> procesa el nodo actual y devuelve su primer hijo. A continuación fija <code>pendingProps</code> como <code>memoizedProps</code>; si hay un hijo, avanza hacia él y, si no lo hay, llama a <code>completeUnitOfWork</code>.</p>
<h3 id="beginwork"><a class="anchor" href="#beginwork">beginWork</a></h3>
<p><code>beginWork</code> recorre los nodos Fiber de arriba abajo y realiza en cada uno los cálculos necesarios. Está definida en <code>ReactFiberBeginWork.js</code> y, por dentro, bifurca la ejecución con un enorme <strong>switch</strong> basado en el <code>tag</code> del Fiber.</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>La clave está en la <strong>comprobación de bailout</strong> al principio. Si las props y el context son iguales que antes, <code>bailoutOnAlreadyFinishedWork</code> omite el subárbol completo. Este es uno de los caminos más importantes para optimizar el rendimiento de React.</p>
<p>El valor devuelto por <code>beginWork</code> es el <strong>primer Fiber hijo</strong>. Si existe, pasa a ser el siguiente <code>workInProgress</code>; si no existe (<code>null</code>), se entra en <code>completeUnitOfWork</code>.</p>
<h3 id="completework"><a class="anchor" href="#completework">completeWork</a></h3>
<p><code>completeWork</code> comienza en los nodos hoja y finaliza el trabajo mientras asciende hacia los padres.</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>Las principales tareas realizadas en <code>completeWork</code> son las siguientes.</p>
<ul>
<li><strong>Para un HostComponent</strong>: crea el nodo DOM real (<code>createInstance</code>) y añade los DOM hijos mediante append. Si el DOM ya existe, recopila las props modificadas y las guarda en <code>updateQueue</code>.</li>
<li><strong><code>bubbleProperties()</code></strong>: agrega los flags de los hijos en <code>subtreeFlags</code>. Esta información se utiliza durante la Commit Phase para optimizar la omisión de subárboles.</li>
</ul>
<p>En resumen, el recorrido funciona así: <strong>desciende siguiendo child (beginWork) -> al completar un nodo hoja avanza a sibling -> si no hay hermanos, asciende siguiendo return (completeWork)</strong>. Este es el orden de búsqueda en profundidad de Fiber.</p>
<h3 id="las-colas-de-tareas-del-scheduler"><a class="anchor" href="#las-colas-de-tareas-del-scheduler">Las colas de tareas del Scheduler</a></h3>
<p>El paquete Scheduler de React mantiene internamente <strong>dos min-heaps (montículos mínimos)</strong>.</p>
<pre><code>timerQueue (대기실)                    taskQueue (실행 대기열)
┌──────────────────┐                  ┌──────────────────┐
│ 아직 시작 시간이     │   startTime      │ 지금 실행 가능한     │
│ 안 된 태스크들       │ ──경과 시──→      │ 태스크들           │
│                  │                  │                  │
│ 정렬: startTime   │                  │ 정렬: expiration  │
│ (빠른 순)          │                  │ Time (임박한 순)   │
└──────────────────┘                  └──────────────────┘
</code></pre>
<p><strong>taskQueue</strong> es la cola de tareas «que pueden ejecutarse ahora mismo». Cuanto menor sea <code>expirationTime</code> (= startTime + timeout), es decir, cuanto más próxima esté la expiración, antes se ejecutan. <strong>timerQueue</strong> es la sala de espera de las tareas «cuyo momento de ejecución todavía no ha llegado». En cuanto el tiempo actual supera startTime, se trasladan a taskQueue.</p>
<p>Entonces, ¿cómo se determina el timeout que define expirationTime? Cada actualización recibe un timeout propio según su prioridad (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> expira nada más crearse y recibe la máxima prioridad en cuanto entra en taskQueue. (Expirar en el mismo momento de nacer es un destino un poco triste). Los 250 ms de <strong>UserBlocking</strong> se ajustan al umbral en que una persona percibe una respuesta como lenta —entre 100 y 300 ms—. Si tras un clic no ocurre nada durante 0,25 segundos, el usuario se irrita. Los 5 segundos de <strong>Normal</strong> pueden parecer generosos, pero garantizan que el trabajo se procesará incluso en el peor caso. En la práctica, se ejecuta en cuanto terminan las tareas anteriores. Los aproximadamente 12,4 días de <strong>Idle</strong> equivalen de hecho al infinito. Solo se ejecuta cuando ha terminado todo lo demás. (Como es muy poco probable mantener el navegador abierto durante 12 días, podemos considerarlo infinito).</p>
<p>Estos timeouts también funcionan como mecanismo para evitar la <strong>inanición (starvation)</strong>. Por baja que sea la prioridad, cuando transcurre el timeout el trabajo expira y se fuerza su ejecución. Aunque sigan llegando tareas de alta prioridad, las de baja prioridad nunca quedan ignoradas para siempre.</p>
<h2 id="render-phase-y-commit-phase"><a class="anchor" href="#render-phase-y-commit-phase">Render Phase y Commit Phase</a></h2>
<p>Hasta ahora hemos visto la estructura y la planificación de Fiber. Organicemos ahora el flujo completo para entender cómo se combinan todas estas piezas y producen una actualización real de la UI.</p>
<p>Internamente, Fiber atraviesa dos etapas: la <strong>Render Phase</strong> y la <strong>Commit Phase</strong>. Esta separación es el diseño esencial que hace posible el modelo de concurrencia de React. Si quieres observar directamente el flujo de funcionamiento de Fiber, haz clic en la imagen siguiente.</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="Demo interactiva que recorre React Fiber paso a paso, de la fase de render a la de layout, junto al árbol de componentes y la pila de trabajo" 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>La Render Phase es la etapa que <strong>calcula qué cambios necesita</strong> la UI. En ella no se modifica realmente el DOM. Su característica más importante es que <strong>puede interrumpirse y reanudarse de forma asíncrona</strong>.</p>
<p>Esta etapa gira en torno a <code>beginWork</code> y <code>completeWork</code>, que ya hemos visto.</p>
<p>En <strong>beginWork(fiber)</strong> se ejecuta la lógica correspondiente al tipo de cada Fiber —FunctionComponent, ClassComponent, HostComponent, etc.— y se crean y enlazan sus nodos Fiber hijos. Si las props son iguales a las anteriores, se puede omitir el trabajo mediante memoization (bailout).</p>
<p>En <strong>completeWork(fiber)</strong> se preparan la creación del DOM y la información de los effects. Después, <code>bubbleProperties()</code> agrega los flags de los hijos en <code>subtreeFlags</code> y completa la información mientras asciende hacia los padres.</p>
<p>Como esta etapa no modifica directamente el DOM, el trabajo puede interrumpirse en cualquier momento y retomarse más tarde sin exponer al usuario una UI incompleta. Esta es la base del modo Concurrent.</p>
<h3 id="subtreeflags"><a class="anchor" href="#subtreeflags">subtreeFlags</a></h3>
<p>Durante la Render Phase, cada Fiber registra mediante <strong>flags de bits</strong> qué efectos secundarios (side effects) necesita. Veamos los principales flags definidos en <code>ReactFiberFlags.js</code>.</p>
<ul>
<li><code>Placement</code>: insertar un nodo nuevo en el DOM</li>
<li><code>Update</code>: actualizar propiedades del DOM</li>
<li><code>ChildDeletion</code>: eliminar un nodo hijo</li>
<li><code>Ref</code>: conectar o desconectar una ref</li>
<li><code>Passive</code>: ejecutar un callback de useEffect</li>
<li><code>Snapshot</code>: ejecutar getSnapshotBeforeUpdate</li>
<li><code>Callback</code>: ejecutar un callback del lifecycle</li>
</ul>
<p>Las versiones anteriores de React —hasta la 16— utilizaban una linked list conectada mediante <code>firstEffect</code> -> <code>nextEffect</code> -> <code>lastEffect</code> para reunir únicamente los Fibers con efectos secundarios. Sin embargo, las referencias a Fibers desmontados permanecían y provocaban <strong>memory leaks</strong>; además, resultaba difícil procesar de forma eficiente patrones nuevos como Suspense.</p>
<p>A partir de React 17 se eliminó esta effect list y se adoptó el sistema de <strong>subtreeFlags</strong> (<a href="https://github.com/facebook/react/pull/19381" target="_blank" rel="noopener noreferrer">PR #19381</a>). Durante <code>completeWork</code>, <code>bubbleProperties()</code> agrega los flags de los hijos en el padre.</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>La gran ventaja de esta estructura es que durante la Commit Phase se puede <strong>omitir un subárbol completo</strong>. Si en un Fiber se cumple <code>subtreeFlags &#x26; MutationMask === NoFlags</code>, no hay ningún nodo de ese subárbol que requiera cambios en el DOM, por lo que puede saltarse entero. Esta optimización era imposible con el anterior sistema de linked lists.</p>
<h3 id="commit-phase"><a class="anchor" href="#commit-phase">Commit Phase</a></h3>
<p>La Commit Phase es la etapa que <strong>aplica al DOM real</strong> los cambios calculados durante la Render Phase. Se ejecuta <strong>siempre de forma síncrona</strong> y, una vez iniciada, continúa hasta el final sin interrupciones. Así se evita que el usuario vea una UI actualizada solo a medias.</p>
<p>Internamente, la Commit Phase sigue esta secuencia detallada.</p>
<ol>
<li>
<p><strong>Before Mutation Phase</strong>: <code>commitBeforeMutationEffects()</code></p>
<ul>
<li>Lee el estado actual del DOM antes de modificarlo. Aquí se ejecuta el lifecycle <code>getSnapshotBeforeUpdate</code>. En ese momento el árbol <code>current</code> todavía representa el estado visible, por lo que se pueden capturar con seguridad datos como la posición del scroll o el tamaño del DOM.</li>
</ul>
</li>
<li>
<p><strong>Mutation Phase</strong>: <code>commitMutationEffects()</code></p>
<ul>
<li>Es la etapa en la que se realizan las <strong>operaciones reales sobre el DOM</strong>: insertar nodos nuevos, modificar los existentes y eliminar los innecesarios. <code>componentWillUnmount</code> también se ejecuta aquí, porque <code>current</code> todavía apunta al árbol anterior y permite leer su estado.</li>
</ul>
</li>
<li>
<p><strong>Intercambio de árboles</strong>: <code>root.current = finishedWork</code></p>
<ul>
<li>Es la esencia del doble búfer. El árbol workInProgress asciende a árbol current. Es importante que el intercambio ocurra después de Mutation y antes de Layout: <code>componentWillUnmount</code> debe leer el <strong>árbol anterior</strong>, por lo que se ejecuta durante Mutation, mientras que <code>componentDidMount</code>/<code>componentDidUpdate</code> deben leer el <strong>árbol nuevo</strong>, por lo que se ejecutan durante Layout.</li>
</ul>
</li>
<li>
<p><strong>Layout Phase</strong>: <code>commitLayoutEffects()</code></p>
<p>Una vez modificado el DOM, se ejecutan los trabajos basados en su nuevo estado.</p>
<ul>
<li>Ejecución de <code>componentDidMount</code> y <code>componentDidUpdate</code></li>
<li>Ejecución de los callbacks de <code>useLayoutEffect</code></li>
<li>En este punto <code>current</code> ya apunta al árbol nuevo, por lo que al leer el DOM se obtienen los valores actualizados</li>
</ul>
</li>
<li>
<p><strong>Passive Effects</strong> (asíncronos)</p>
<ul>
<li>El cleanup y el setup de <code>useEffect</code> se planifican por separado y se ejecutan de forma <strong>asíncrona</strong>. Como procesan efectos secundarios que no dependen de cambios en el DOM —obtención de datos, suscripciones a eventos, etc.—, no es necesario ejecutarlos síncronamente. Al procesarlos de forma asíncrona, React cede el control para que el navegador pueda pintar antes la pantalla.</li>
</ul>
</li>
</ol>
<h2 id="concurrent-features-y-fiber"><a class="anchor" href="#concurrent-features-y-fiber">Concurrent Features y Fiber</a></h2>
<p>Veamos ahora qué experiencias de usuario hacen posibles todos los elementos de Fiber estudiados hasta aquí —doble búfer, prioridades basadas en Lanes y Work Loop interrumpible— mediante las Concurrent Features disponibles desde React 18.</p>
<h3 id="usetransition"><a class="anchor" href="#usetransition">useTransition</a></h3>
<p>Al llamar a <code>startTransition(() => setState(...))</code>, se asigna una <code>TransitionLane</code> a esa actualización. Existen 14 TransitionLanes que se distribuyen mediante round-robin —asignándolas una a una por turnos— para evitar colisiones.</p>
<p>Como TransitionLane tiene menos prioridad que SyncLane o DefaultLane, si llega una actualización urgente como una entrada del usuario, React puede <strong>interrumpir</strong> el renderizado de la transition y procesar primero la actualización urgente. Mientras tanto, la pantalla mantiene el árbol <code>current</code> —el estado anterior— y la transition avanza en segundo plano sobre el árbol workInProgress.</p>
<p>Aquí es donde brilla el valor del doble búfer. Un renderizado de transition interrumpido solo afecta al árbol workInProgress; la pantalla que ve el usuario —el árbol current— permanece completamente intacta.</p>
<p>El flag <code>isPending</code> indica que la transition todavía no ha terminado, lo que permite mostrar un indicador de carga u ofrecer un tratamiento similar.</p>
<h3 id="usedeferredvalue"><a class="anchor" href="#usedeferredvalue">useDeferredValue</a></h3>
<p>En el primer renderizado, <code>useDeferredValue(value)</code> devuelve directamente el <code>value</code> recibido. En renderizados posteriores, si el render actual es urgente, devuelve el valor memoized anterior y planifica un render nuevo con TransitionLane. Igual que una Transition, el renderizado diferido se puede interrumpir.</p>
<p>Conceptualmente se parece a <code>startTransition</code>, pero se aplica en el lado que <strong>recibe el valor</strong>, no en el que despacha la actualización. Un caso de uso habitual consiste en reflejar inmediatamente el texto de un campo de búsqueda, pero retrasar el renderizado de la lista de resultados.</p>
<h3 id="suspense"><a class="anchor" href="#suspense">Suspense</a></h3>
<p>Cuando un componente dentro de <code>&#x3C;Suspense></code> lanza una Promise, <code>throwException</code> la captura y marca ese Fiber como <code>Incomplete</code>. Después asciende por la cadena <code>return</code> en busca del límite de Suspense más cercano y hace que este muestre la fallback UI. Cuando la Promise se resuelve, <code>markRootPinged</code> hace ping a la lane correspondiente y React vuelve a renderizar el subárbol suspendido.</p>
<p>En el modo Concurrent se pueden <strong>seguir renderizando los nodos hermanos (sibling)</strong> de un componente suspendido, de modo que una sola petición de datos no bloquea el renderizado del árbol completo. Esto es posible porque la estructura de linked list de Fiber permite desplazarse libremente hacia sibling.</p>
<h3 id="streaming-ssr-y-selective-hydration"><a class="anchor" href="#streaming-ssr-y-selective-hydration">Streaming SSR y Selective Hydration</a></h3>
<p><code>renderToPipeableStream</code> de React 18 utiliza los límites de Suspense.</p>
<ul>
<li><strong>Servidor</strong>: cuando un límite de Suspense se suspende, envía primero el HTML de fallback y, cuando los datos están listos, transmite después el contenido real mediante una etiqueta <code>&#x3C;script></code></li>
<li><strong>Cliente (Selective Hydration)</strong>: cada límite de Suspense puede hidratarse de forma <strong>independiente</strong>. Si el usuario hace clic en una zona todavía no hidratada, <code>SelectiveHydrationLane</code> procesa <strong>con prioridad</strong> la hydration de ese límite y después despacha el evento</li>
</ul>
<p>Todo esto es posible porque cada límite de Suspense es un nodo Fiber que se puede planificar de manera independiente. En definitiva, el diseño esencial de la arquitectura Fiber —«dividir el trabajo, asignarle prioridades e interrumpirlo/reanudarlo»— constituye la base de todas estas funcionalidades.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>Si hubiera que resumir este artículo en una frase: <strong>React Fiber es una arquitectura que sustituye la recursión por iteración y traslada el call stack al heap para poder interrumpir y reanudar el renderizado</strong>.</p>
<p>Para conseguirlo combina numerosos diseños sofisticados: una estructura de árbol basada en linked lists, doble búfer, un sistema de prioridades basado en Lanes y un scheduler basado en MessageChannel. Todos persiguen un mismo objetivo: <strong>maximizar la capacidad de respuesta de la UI percibida por el usuario</strong>.</p>
<p>Por supuesto, la implementación interna de Fiber sigue cambiando con cada versión de React, y lo explicado aquí no deja de ser una fotografía tomada en un momento concreto. Sin embargo, creo que la filosofía esencial de Fiber —«dividir el trabajo, asignarle prioridades e interrumpirlo y reanudarlo»— permanecerá inalterada.</p>
<p>Espero que este artículo haya mostrado que React Fiber no es una simple palabra clave para entrevistas, sino la arquitectura de runtime que sostiene todas las funciones de React. No existe una única respuesta correcta, pero también espero que quienes lean este artículo examinen directamente el código fuente y construyan su propia comprensión.</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">Código fuente de React, 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">Código fuente de React, 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">Código fuente de React, 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">Código fuente de React, 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">Código fuente de React, 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">Código fuente de React, 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">Artículo del blog de React v18.0</a></div>
</div>
</div></details>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>React</category>
        </item>
        <item>
            <title><![CDATA[Por qué React usa MessageChannel]]></title>
            <link>https://hooninedev.com/es/250515</link>
            <guid isPermaLink="false">https://hooninedev.com/es/250515</guid>
            <pubDate>Thu, 15 May 2025 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta entrada quiero hablar de por qué React programa su trabajo con MessageChannel en lugar de requestIdleCallback. Está pensada para desarrolladores frontend que, estudiando Fiber, leyeron que Rea...]]></description>
            <content:encoded><![CDATA[<p>En esta entrada quiero hablar de <strong>por qué React programa su trabajo con MessageChannel en lugar de requestIdleCallback</strong>.</p>
<p>Está pensada para desarrolladores frontend que, estudiando Fiber, leyeron que React «trabaja un poco cada vez que el navegador está ocioso» y luego se confundieron al encontrar <code>MessageChannel</code> en el código fuente real de React. Adelanto la respuesta: <code>requestIdleCallback</code> no se llamaba con la frecuencia que React necesitaba, y <code>setTimeout</code> añade un retraso de más de 4 ms cuando las llamadas se anidan. Por eso el paquete Scheduler de React usa <code>MessageChannel</code>, que en el navegador permite programar la siguiente macrotask sin ningún retraso artificial. Cuánto pesan en realidad esos 4 ms lo compruebo con números medidos en Chrome headless.</p>
<h2 id="métodos-de-programación-descartados"><a class="anchor" href="#métodos-de-programación-descartados">Métodos de programación descartados</a></h2>
<p>Al explicar el concepto de Fiber se suele usar código que reparte el trabajo con <code>requestIdleCallback</code>. Es un modelo en el que se procesa una unidad de trabajo cada vez que el navegador no tiene nada que hacer. React también usó esta API al principio, y pasó por varios PR hasta llegar a su forma actual.</p>
<ul>
<li><strong>Enero de 2017</strong>: React usaba el <code>requestIdleCallback</code> nativo y, en los navegadores que no lo tenían, un polyfill que lo imitaba con <code>requestAnimationFrame</code> y <code>postMessage</code> (<a href="https://github.com/facebook/react/pull/8833" target="_blank" rel="noopener noreferrer">PR #8833</a>). Los navegadores sin esta API, como Safari, quedaron a cargo del polyfill desde el principio. La versión estable de Safari <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback#browser_compatibility" target="_blank" rel="noopener noreferrer">sigue sin tener esta API en octubre de 2026</a>.</li>
<li><strong>Marzo y abril de 2018</strong>: se añadió un flag para usar el polyfill aunque existiera la API nativa. Andrew Clark escribió en la descripción del PR que el objetivo era probar si el polyfill reducía los problemas de starvation (trabajo que no consigue turno para ejecutarse y se pospone una y otra vez) que venían observando, y añadió que era difícil asegurarlo porque esos problemas costaban de reproducir (<a href="https://github.com/facebook/react/pull/12385" target="_blank" rel="noopener noreferrer">PR #12385</a>). Un mes después, tras concluir internamente que funcionaba mejor que la implementación nativa, se eliminó el flag y el polyfill quedó como definitivo (<a href="https://github.com/facebook/react/pull/12648" target="_blank" rel="noopener noreferrer">PR #12648</a>).</li>
<li><strong>Noviembre de 2018</strong>: el evento message que el polyfill enviaba a <code>window</code> pasó a enviarse por un <code>MessageChannel</code>. Si se envía a <code>window</code>, todos los demás manejadores de message de la página también se ejecutan en cada frame (<a href="https://github.com/facebook/react/pull/14234" target="_blank" rel="noopener noreferrer">PR #14234</a>).</li>
<li><strong>Julio de 2019</strong>: en lugar de adivinar el siguiente vsync y ceder al final del frame, un flag experimental introdujo un bucle que trabaja 5 ms dentro de un evento message y luego cede (<a href="https://github.com/facebook/react/pull/16214" target="_blank" rel="noopener noreferrer">PR #16214</a>). Este bucle no usa <code>requestAnimationFrame</code> en absoluto.</li>
<li><strong>Agosto y noviembre de 2019</strong>: en agosto llegó un informe según el cual las pruebas de rendimiento mostraban mejor uso de CPU con el bucle de mensajes (<a href="https://github.com/facebook/react/pull/16271" target="_blank" rel="noopener noreferrer">PR #16271</a>, no fusionado), y en noviembre se eliminó la implementación con rAF (<a href="https://github.com/facebook/react/pull/17252" target="_blank" rel="noopener noreferrer">PR #17252</a>).</li>
</ul>
<p>Por qué se produce la starvation puede deducirse de la propia definición de <code>requestIdleCallback</code>. La <a href="https://w3c.github.io/requestidlecallback/" target="_blank" rel="noopener noreferrer">especificación del W3C</a> deja que el navegador decida qué es un periodo de inactividad (idle period) y pone como uno de sus ejemplos el tiempo sobrante entre un frame y el siguiente. Durante una animación esos periodos llegan a menudo, pero en una pantalla de 60 Hz suelen durar menos de 16 ms. Si el hilo principal está ocupado con tareas largas, incluso esos se reducen, y a mi juicio el trabajo de React se pospone en la misma medida. Dan Abramov también escribió en un comentario de agosto de 2018 que React dejó de usar esta API porque «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>Los motivos para abandonar después el enfoque con <code>requestAnimationFrame</code> están en la descripción del PR #16214. Ese enfoque tenía que adivinar cuándo llegaría el siguiente vsync (la señal ligada al ciclo de refresco de la pantalla). Empezaba suponiendo 30 fps, con una duración de frame de 33,33 ms, y cuando dos intervalos consecutivos entre frames eran ambos más cortos, reducía la duración del frame al mayor de los dos. El plazo del frame actual era la hora de inicio del frame más esa duración (<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 justo antes del PR #17252</a>). Como su única regla reducía la duración, detectaba si la tasa de refresco subía después de cargar la página, pero no si bajaba, tal como dice el PR.</p>
<p>El bucle de mensajes cede cada 5 ms esté donde esté en el ciclo de vsync. La descripción del PR esperaba que así el hilo principal mantuviera su capacidad de respuesta incluso en pantallas con tasas de refresco muy altas («should keep the main thread responsive»). El mismo PR señalaba también el riesgo de que ceder más a menudo aumentara la competencia con otras tareas del navegador, y que no estaba claro cuánto se limitan los eventos message en una pestaña en segundo plano. El comentario actual del código del Scheduler dice que la mayoría de las tareas no necesitan alinearse con los límites de frame.</p>
<h2 id="messagechannel"><a class="anchor" href="#messagechannel">MessageChannel</a></h2>
<p>El mecanismo de programación que quedó tras retirar rAF es <strong>MessageChannel</strong>. Abajo están las <a href="https://github.com/facebook/react/blob/v19.3.0/packages/scheduler/src/forks/Scheduler.js#L530-L561" target="_blank" rel="noopener noreferrer">líneas 530 a 561 de Scheduler.js</a> en React v19.3.0, con los comentarios recortados.</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>Hay tres ramas. Los navegadores actuales no tienen <code>setImmediate</code>, así que se toma la segunda rama, <code>MessageChannel</code>. Según el comentario del código, la primera rama existe para Node.js y el antiguo IE, y <code>MessageChannel</code> impide que un proceso de Node.js termine, mientras que <code>setImmediate</code> no (<a href="https://github.com/facebook/react/issues/20756" target="_blank" rel="noopener noreferrer">facebook/react#20756</a>). Por eso el entorno node de Jest toma la ruta de <code>setImmediate</code>. Desde Jest 27, al entorno jsdom se le quitó <code>setImmediate</code> de sus globales (<a href="https://github.com/jestjs/jest/pull/11222" target="_blank" rel="noopener noreferrer">jestjs/jest#11222</a>) y tampoco tiene <code>MessageChannel</code>, así que toma la ruta de <code>setTimeout</code>.</p>
<p>El propósito de programar así el siguiente turno es devolver el hilo principal al navegador. Mientras JavaScript ocupa el hilo principal, el navegador no puede procesar entradas ni pintar la pantalla. Sin embargo, solo ceden a mitad de camino los renders divididos en el tiempo, como los de Transition y Retry. El Reconciler de React v19.3.0 renderiza hasta el final sin ceder si el render incluye una blocking lane como Sync, InputContinuous o Default, incluye una lane que esperó tanto que caducó, o se llamó con <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">línea 1168 de ReactFiberWorkLoop.js</a>).</p>
<p>En esos renders, el Reconciler pregunta <code>shouldYield()</code> cada vez que procesa un 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">líneas 3073 a 3078 de ReactFiberWorkLoop.js</a>). Esa función la exporta el Scheduler, y la decisión real la toma <code>shouldYieldToHost()</code> dentro del Scheduler. El criterio es el tiempo transcurrido desde que empezó la tarea de mensaje actual. Cuando ese tiempo alcanza <code>frameInterval</code>, cede, y si se llamó a <code>requestPaint()</code> después de un commit, cede sin importar el tiempo. El valor inicial de <code>frameInterval</code> es la constante de <code>SchedulerFeatureFlags.js</code> llamada <code>frameYieldMs</code>, es decir, <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">línea 11</a>). Los 5 ms no son el tamaño de un fragmento de trabajo sino el umbral de tiempo para comprobar si hay que ceder. Si renderizar un solo componente tarda 20 ms, esos 20 ms no se dividen. Todo esto describe la build stable. En las builds experimental, <code>enableAlwaysYieldScheduler</code> del mismo archivo está activado, así que el Scheduler cede al navegador en cuanto termina una tarea, salvo que la siguiente ya haya caducado, en lugar de agotar los 5 ms, y no atiende la señal de <code>requestPaint()</code>.</p>
<h2 id="el-retraso-de-4-ms-de-settimeout"><a class="anchor" href="#el-retraso-de-4-ms-de-settimeout">El retraso de 4 ms de setTimeout</a></h2>
<p>¿Por qué no <code>setTimeout</code>, sino <code>MessageChannel</code>, para programar el siguiente fragmento después de ceder? Los <a href="https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#timer-initialisation-steps" target="_blank" rel="noopener noreferrer">pasos de inicialización de temporizadores</a> de la especificación HTML elevan a 4 ms el retraso de un <code>setTimeout</code> cuyo nivel de anidamiento supera 5 si es menor que eso. Al medir en Chrome headless una cadena en la que <code>setTimeout(fn, 0)</code> se vuelve a programar a sí mismo, los intervalos de las llamadas 1 a 6 fueron de 0 a 0,1 ms, y a partir de la 7.ª se añadieron de 4 a 5 ms (normalmente unos 5 ms). Los mensajes de un <code>MessageChannel</code> no tienen ese retraso mínimo. Un mensaje simplemente entra en la cola de tareas, así que el navegador puede intercalar el procesamiento de entradas o el renderizado entre medias, y ese hueco es justo lo que busca el Scheduler.</p>
<p>4 ms parecen poco, pero en un bucle que cede cada 5 ms la historia cambia. Dividí 200 ms de trabajo en 40 fragmentos de 5 ms y programé el siguiente fragmento con <code>MessageChannel</code> y con <code>setTimeout(work, 0)</code>. Si lo pegas en la consola de DevTools, funciona tal cual.</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>Estos son los resultados de ejecutar tres variantes ocho veces cada una el 2026-10-08 en Chrome 154.0.8037.98 (headless) sobre macOS. No se midió en Safari ni en Firefox. La tercera fila de la tabla sustituye <code>busy(5); done += 5;</code> por <code>busy(200); done += 200;</code> para no dividir el trabajo.</p>
<table>
<thead>
<tr>
<th>Cómo se programa el siguiente fragmento</th>
<th>Tiempo hasta terminar</th>
<th>Frames dibujados mientras tanto</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>MessageChannel</code></td>
<td>201 ms</td>
<td>12 a 13</td>
</tr>
<tr>
<td><code>setTimeout(work, 0)</code></td>
<td>unos 365 a 371 ms</td>
<td>21 a 22</td>
</tr>
<tr>
<td>Sin dividir</td>
<td>200 ms</td>
<td>0</td>
</tr>
</tbody>
</table>
<p>Con <code>setTimeout</code>, después de las seis primeras llamadas cada fragmento ganó de 4 a 5 ms de tiempo muerto, y el mismo trabajo tardó unas 1,8 veces más. Las cifras varían según la máquina y la carga, pero la proporción de unas 1,8 veces se mantuvo en las repeticiones. El mayor número de frames se debe a que el trabajo terminó tarde y el intervalo medido se alargó. No significa mejor capacidad de respuesta. Sin dividir, el trabajo termina en 200 ms, pero durante ese tiempo no se dibuja ni un solo frame. <code>MessageChannel</code> terminó el trabajo en los mismos 200 ms y siguió produciendo frames, por lo general a unos 60 fps.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En resumen, lo que React necesitaba no era una API que esperara a que el navegador estuviera ocioso, sino una que le permitiera trabajar un rato breve y programar enseguida su siguiente turno. <code>requestIdleCallback</code> no se llamaba con la insistencia que React necesitaba; el enfoque con <code>requestAnimationFrame</code> tenía que adivinar cuándo llegaría el vsync; y <code>setTimeout</code>, una vez anidado, hacía descansar cada fragmento más de 4 ms. <code>MessageChannel</code> es lo que cumplió esas condiciones.</p>
<p>Cómo son los nodos Fiber, las unidades de trabajo que este Scheduler reparte, y cómo los recorre el Work Loop lo trato en <a href="/250520" target="_blank" rel="noopener noreferrer">React Fiber al completo</a>. Ojalá que, la próxima vez que encuentres <code>MessageChannel</code> en el código fuente de React, recuerdes por un momento por qué está ahí.</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 de 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[¿Puede Biome reemplazar a ESLint y Prettier?]]></title>
            <link>https://hooninedev.com/es/241201</link>
            <guid isPermaLink="false">https://hooninedev.com/es/241201</guid>
            <pubDate>Sun, 01 Dec 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[En esta publicación quiero hablar de una herramienta llamada Biome. El equipo en el que trabajo tenía bastantes dificultades para mantener un estilo de código coherente en un entorno donde se usaban d...]]></description>
            <content:encoded><![CDATA[<p>En esta publicación quiero hablar de una herramienta llamada Biome.</p>
<p>El equipo en el que trabajo tenía bastantes dificultades para mantener un estilo de código coherente en un entorno donde se usaban distintos IDE, como WebStorm y VSCode. También resultaba tedioso gestionar archivos de configuración separados para cada IDE, y era frecuente que en las revisiones de código surgieran observaciones sobre diferencias de formato sin relación con la lógica.</p>
<p>En esta situación, las reglas de formato de ESLint quedaron Deprecated y tuvimos que buscar una alternativa nueva. La combinación <strong>Prettier + ESLint</strong> exigía configuración adicional para evitar conflictos entre ambas herramientas, mientras que <strong>@stylistic/eslint-plugin-ts</strong> todavía se encontraba en una etapa temprana dentro de la comunidad y no contaba con suficiente validación de estabilidad. Fue entonces cuando Biome despertó nuestro interés.</p>
<p>Entonces, ¿qué es exactamente Biome y de verdad puede reemplazar a ESLint y Prettier?</p>
<h2 id="qué-es-biome"><a class="anchor" href="#qué-es-biome">¿Qué es Biome?</a></h2>
<p>Biome es una toolchain todo en uno (All-in-One) para proyectos web. Ofrece de forma integrada el formateo y linting de código JavaScript, TypeScript, JSX, CSS, JSON, GraphQL y más desde una única herramienta. Su filosofía central consiste en resolver con un solo binario las funciones que antes desempeñaban ESLint y Prettier por separado.</p>
<p>El antecesor de Biome fue <a href="https://github.com/rome/tools" target="_blank" rel="noopener noreferrer">Rome</a>. <strong>Rome Tools Inc.</strong> arrancó con grandes ambiciones tras recaudar 4,5 millones de dólares de inversión de riesgo en 2021, pero a mediados de 2023 despidió a toda su plantilla y archivó el repositorio. Después, los principales contributors hicieron un fork del proyecto y lo relanzaron como Biome en agosto de 2023. Tras dejar atrás la imagen de Rome de «prometer demasiado y cumplir poco», ha ido ganando confianza con releases prácticas y constantes.</p>
<p>Su característica más destacada es que está escrito en Rust. Más adelante veremos en detalle qué diferencia supone esto para el rendimiento.</p>
<h2 id="por-qué-usar-biome"><a class="anchor" href="#por-qué-usar-biome">¿Por qué usar Biome?</a></h2>
<p>Los motivos para elegir Biome pueden resumirse en tres puntos principales.</p>
<p><strong>Una sola herramienta se encarga tanto del formateo como del linting.</strong> Con la combinación ESLint + Prettier hacía falta configuración adicional, como <code>eslint-config-prettier</code>, para impedir conflictos entre las reglas de ambas herramientas. Biome elimina esa complejidad desde la raíz.</p>
<p><strong>Su rendimiento es abrumador.</strong> Según los benchmarks oficiales, es unas 35 veces más rápido que Prettier y unas 15 veces más rápido que ESLint. Más adelante compararemos directamente qué representan estas cifras en la práctica.</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="Comparativa de formateo del sitio de Biome: 0,41 s frente a los 14,35 s de Prettier sobre 171.127 líneas en 2.104 archivos, unas 35 veces más rápido" width="1680" height="736" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p><strong>Es compatible con las herramientas existentes.</strong> Ofrece aproximadamente un 97 % de compatibilidad de formato con Prettier e incluye de serie las principales reglas de ESLint. También incorpora reglas de plugins habituales como <code>eslint-plugin-react-hooks</code> y <code>eslint-plugin-jsx-a11y</code>, por lo que la carga de la migración es relativamente pequeña.</p>
<h2 id="cómo-se-utiliza"><a class="anchor" href="#cómo-se-utiliza">¿Cómo se utiliza?</a></h2>
<p>Configurar Biome es bastante sencillo. La <a href="https://biomejs.dev/guides/getting-started/" target="_blank" rel="noopener noreferrer">documentación oficial</a> lo explica con claridad, así que conviene consultarla.</p>
<p>Primero, instala 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>A continuación, genera el archivo de configuración.</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>Esto crea un archivo <code>biome.json</code>. En él puedes definir las reglas de formateo y linting del equipo.</p>
<p>También hay que instalar una extensión para el IDE. Si utilizas VSCode, instala <a href="https://marketplace.visualstudio.com/items?itemName=biomejs.biome" target="_blank" rel="noopener noreferrer">VSCode Biome</a>; si utilizas WebStorm, instala el plugin <a href="https://plugins.jetbrains.com/plugin/22761-biome" target="_blank" rel="noopener noreferrer">WebStorm Biome</a>.</p>
<p>Por último, añade la siguiente configuración al <code>settings.json</code> de VSCode para aplicar automáticamente el formateo y el linting cada vez que guardes.</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="hagamos-una-comparación-directa"><a class="anchor" href="#hagamos-una-comparación-directa">Hagamos una comparación directa</a></h2>
<p>Decir simplemente que es rápido no permite apreciar la diferencia, así que comparé directamente Biome con ESLint + Prettier en el mismo proyecto. Biome aparece a la izquierda y ESLint + Prettier a la derecha.</p>
<h3 id="tiempo-de-ejecución-local-de-un-proyecto-vite"><a class="anchor" href="#tiempo-de-ejecución-local-de-un-proyecto-vite">Tiempo de ejecución local de un proyecto Vite</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="Servidor de desarrollo de Vite con Biome, listo en 506 ms" 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="Servidor de desarrollo de Vite con ESLint y Prettier, listo en 630 ms" width="766" height="248" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome tardó <strong>506ms</strong>, frente a los <strong>630ms</strong> de ESLint + Prettier, lo que supone un tiempo de ejecución aproximadamente un 20 % más rápido.</p>
<h3 id="tiempo-de-build-de-un-proyecto-vite"><a class="anchor" href="#tiempo-de-build-de-un-proyecto-vite">Tiempo de build de un proyecto Vite</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="Compilación de Vite con Biome: 117,13 segundos" 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="Compilación de Vite con ESLint y Prettier: 131,48 segundos" width="1022" height="228" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome tardó <strong>117.13s</strong>, frente a los <strong>131.48s</strong> de ESLint + Prettier, lo que supone un tiempo de build aproximadamente un 10 % más rápido.</p>
<h3 id="tarea-de-linting"><a class="anchor" href="#tarea-de-linting">Tarea de 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="Ejecución de lint con Biome: 0,79 segundos y 0,470 segundos de CPU" 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="Ejecución de lint con ESLint: 16,32 segundos y 8,600 segundos de CPU" width="974" height="186" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>La mayor diferencia apareció en la tarea de linting. Biome tardó <strong>0.79s</strong> (CPU 0.470s), mientras que ESLint tardó <strong>16.32s</strong> (CPU 8.600s), por lo que <strong>Biome ofreció un rendimiento unas 20 veces mayor</strong>. El uso de CPU también fue mucho más eficiente.</p>
<p>La diferencia ya se percibe claramente en el entorno de desarrollo, pero se vuelve aún más drástica cuando una pipeline de CI/CD comprueba cientos de archivos. Como Biome puede ejecutar directamente su binario sin instalarlo mediante npm, también permite ahorrar tiempo en el cold start de CI.</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="El personaje Patricio con la mano en la barbilla, pensativo" width="265" height="190" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Mmm... (A estas alturas, es más difícil encontrar un motivo para no usarlo.)</p>
<h2 id="por-qué-es-tan-rápido"><a class="anchor" href="#por-qué-es-tan-rápido">¿Por qué es tan rápido?</a></h2>
<p>«Es rápido porque está hecho con Rust» es una afirmación correcta, pero no basta para explicarlo. Veamos los factores técnicos concretos que generan la ventaja de rendimiento de Biome.</p>
<h3 id="rendimiento-de-bajo-nivel-de-rust"><a class="anchor" href="#rendimiento-de-bajo-nivel-de-rust">Rendimiento de bajo nivel de Rust</a></h3>
<table>
<thead>
<tr>
<th><img src="/content/241201/5.webp" alt="Uso medio de CPU con 10, 100 y 300 conexiones simultáneas: Node.js se mantiene en 89-91% y Rust llega a 145-206% usando varios núcleos" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
<th><img src="/content/241201/6.webp" alt="Uso medio de memoria en las mismas condiciones: Node.js consume 64-89 MB y Rust 6-14 MB" width="1200" height="1000" loading="lazy" fetchpriority="low" decoding="async"></th>
</tr>
</thead>
</table>
<p>Biome está escrito en Rust, un lenguaje de programación de sistemas. Rust busca abstracciones de coste cero (Zero-cost Abstraction), de modo que incluso las abstracciones de alto nivel ofrecen el mismo rendimiento que el código de bajo nivel optimizado manualmente. Además, como administra la memoria mediante un sistema de ownership sin garbage collector (GC), no sufre el overhead de runtime provocado por el GC.</p>
<p>En cambio, ESLint y Prettier están escritos en JavaScript y se ejecutan sobre el runtime de Node.js. Aunque la compilación JIT (Just-In-Time) del motor V8 optimiza JavaScript, no puede evitar por completo las limitaciones fundamentales de un lenguaje interpretado ni el coste de la recolección de basura.</p>
<h3 id="arquitectura-de-parsing-único"><a class="anchor" href="#arquitectura-de-parsing-único">Arquitectura de parsing único</a></h3>
<p>Biome analiza el código una sola vez con un único parser para generar un AST (Abstract Syntax Tree, árbol de sintaxis abstracta). Después reutiliza ese AST tanto para el formateo como para el linting.</p>
<p>¿Qué ocurre cuando se usa la combinación ESLint + Prettier? ESLint analiza el código, crea un AST y realiza el linting; después, Prettier vuelve a analizar el mismo código, crea otro AST y realiza el formateo. Es decir, el mismo archivo se analiza dos veces. La arquitectura de parsing único de Biome elimina esta duplicación desde el origen.</p>
<h3 id="procesamiento-paralelo-nativo"><a class="anchor" href="#procesamiento-paralelo-nativo">Procesamiento paralelo nativo</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="Procesamiento síncrono frente a asíncrono: el síncrono alterna petición y respuesta, el asíncrono envía peticiones seguidas y recoge respuestas después" width="800" height="514" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Biome aprovecha el modelo de concurrencia de Rust para procesar archivos en paralelo mediante varios threads. Divide el trabajo en unidades pequeñas y distribuye eficientemente la carga entre los threads con un scheduler de work-stealing. Como el sistema de ownership de Rust impide los data races en tiempo de compilación, también se minimiza el coste de sincronización durante el runtime.</p>
<p>Node.js utiliza por defecto un modelo single-thread basado en un event loop. Es posible procesar en paralelo mediante Worker Threads, pero esto introduce overhead adicional por la creación de threads y el message passing. Biome utiliza directamente threads nativos a nivel del sistema operativo, por lo que puede aprovechar al máximo los núcleos de CPU sin ese overhead.</p>
<h3 id="procesamiento-de-ast-eficiente-en-memoria"><a class="anchor" href="#procesamiento-de-ast-eficiente-en-memoria">Procesamiento de AST eficiente en memoria</a></h3>
<p><img src="/content/241201/4.svg" alt="Ejemplo de árbol sintáctico: un -1 unario a la izquierda y un 1 + 2 binario a la derecha, con nodos de operador y de entero" width="446" height="360" loading="lazy" fetchpriority="low" decoding="async"></p>
<p>Biome utiliza un CST (Concrete Syntax Tree, árbol de sintaxis concreta). Según la documentación oficial de arquitectura de Biome, este CST implementa el patrón Green/Red Tree sobre un fork interno de la biblioteca rowan y conserva toda la información del código original, incluidos comentarios y espacios en blanco. La asignación de memoria al estilo arena de rowan coloca los nodos en regiones contiguas de memoria, mejora la localidad de caché (Cache Locality) de la CPU y minimiza asignaciones innecesarias de objetos.</p>
<p>En el procesamiento de AST basado en objetos de JavaScript, cada nodo existe como un objeto independiente en el heap, por lo que la memoria queda dispersa y aumenta la presión sobre el GC. El enfoque de Biome permite recorrer el árbol más rápido utilizando menos memoria.</p>
<h2 id="entonces-conviene-adoptar-biome"><a class="anchor" href="#entonces-conviene-adoptar-biome">Entonces, ¿conviene adoptar Biome?</a></h2>
<p>El rendimiento y la comodidad de Biome son claramente atractivos. Sin embargo, no creo que adoptarlo sin condiciones sea la respuesta correcta para todos los proyectos. Veamos algunas consideraciones prácticas.</p>
<h3 id="cuándo-encaja-biome"><a class="anchor" href="#cuándo-encaja-biome">Cuándo encaja Biome</a></h3>
<ul>
<li>Cuando mantienes una <strong>base de código de gran tamaño</strong> y el rendimiento del build y del linting es importante</li>
<li>Cuando quieres reducir el tiempo de comprobación del código en una pipeline de CI/CD</li>
<li>Cuando estás cansado de la complejidad de configurar ESLint + Prettier</li>
<li>Cuando empiezas un proyecto nuevo y quieres una configuración de herramientas sencilla</li>
</ul>
<p>Mi equipo también mantenía un proyecto de gran tamaño en el que el linting consumía mucho tiempo de la pipeline de CI, y los desarrolladores sufrían la lentitud del proceso, por lo que decidimos adoptar Biome.</p>
<h3 id="aspectos-que-requieren-atención"><a class="anchor" href="#aspectos-que-requieren-atención">Aspectos que requieren atención</a></h3>
<p><strong>La mayor limitación es el ecosistema de plugins.</strong> ESLint cuenta con miles de plugins de la comunidad, mientras que Biome se centra en reglas integradas. Incluye muchas reglas de plugins importantes como <code>eslint-plugin-react</code>, <code>eslint-plugin-react-hooks</code>, <code>eslint-plugin-jsx-a11y</code>, <code>eslint-plugin-unicorn</code> y <code>typescript-eslint</code>, pero no se han portado todas las reglas de cada plugin. Se ha anunciado para Biome v2 un sistema de plugins basado en GritQL, aunque todavía se encuentra en fase experimental. Los proyectos que necesiten reglas específicas de un framework, como <code>@next/eslint-plugin-next</code> o <code>eslint-plugin-angular</code>, deben plantear la migración con cautela.</p>
<p><strong>También hay que comprobar el alcance del soporte de lenguajes.</strong> JavaScript, TypeScript, JSX, CSS, JSON y GraphQL tienen soporte estable, pero en los archivos SFC (Single File Component) de Vue y Svelte solo se admite parcialmente el bloque <code>&#x3C;script></code>. HTML, YAML y Markdown todavía no son compatibles.</p>
<p><strong>No hay que olvidar que ESLint también evoluciona.</strong> Flat Config (<code>eslint.config.js</code>), introducido en ESLint v9 en abril de 2024, simplificó considerablemente la complejidad del antiguo enfoque basado en <code>.eslintrc</code>. Además, con el lanzamiento de <code>@eslint/json</code> en octubre de 2024 y <code>@eslint/css</code> en febrero de 2025, ESLint está ampliando el linting a lenguajes distintos de JavaScript. El proyecto ESLint Stylistic (<code>@stylistic/eslint-plugin</code>) ofrece una opción para gestionar el formateo solo con ESLint y sin Prettier. La ventaja «todo en uno» de Biome se está diluyendo en cierta medida a medida que evoluciona el ecosistema de ESLint.</p>
<p>También conviene recordar la historia de la transición de Rome a Biome. Los inconvenientes que sufrieron los usuarios existentes cuando Rome fue archivado demuestran la importancia de la sostenibilidad de un proyecto al elegir una herramienta. Por suerte, Biome se financia mediante OpenCollective y GitHub Sponsors y mantiene un ritmo constante de releases.</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="Descargas semanales en npm trends durante un año: eslint y prettier entre 30 y 50 millones, mientras biome se pega al eje inferior" width="1680" height="854" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Según npm trends, las descargas semanales de Biome, alrededor de 6,9 millones, todavía están muy lejos de los aproximadamente 120 millones de ESLint y los 82 millones de Prettier. Sin embargo, la velocidad de crecimiento de Biome resulta destacable. En poco más de un año, sus descargas semanales se han multiplicado por más de tres o cuatro, y su adopción ha aumentado de forma especialmente visible en proyectos nuevos.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>Mi respuesta a la pregunta de si Biome puede reemplazar por completo a ESLint y Prettier es <strong>«todavía no, pero es una alternativa muy sólida»</strong>.</p>
<p>Su rendimiento es abrumador, la configuración es concisa y el ritmo de desarrollo es rápido. Sin embargo, la inmadurez del ecosistema de plugins y las limitaciones de soporte para algunos lenguajes pueden ser obstáculos según el proyecto. Lo recomendable es revisar detenidamente el stack tecnológico del proyecto y las necesidades del equipo antes de decidir si adoptarlo.</p>
<p>Algo está claro: el ecosistema de herramientas frontend avanza hacia soluciones «más rápidas, más sencillas y más integradas». Es innegable que Biome está a la cabeza de esa tendencia. Sin duda, es una herramienta cuyo crecimiento futuro merece atención.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>frontend</category>
            <category>JavaScript</category>
        </item>
        <item>
            <title><![CDATA[Zustand, ¿qué eres y por qué eres ProviderLess?]]></title>
            <link>https://hooninedev.com/es/240818</link>
            <guid isPermaLink="false">https://hooninedev.com/es/240818</guid>
            <pubDate>Sun, 18 Aug 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero explicar cómo consigue Zustand gestionar el estado sin un Provider. Mientras usaba Zustand, siempre había dado por sentado que podía gestionar el estado sin un Provider. Hasta ...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero explicar cómo consigue Zustand gestionar el estado sin un Provider.</p>
<p>Mientras usaba Zustand, siempre había dado por sentado que podía gestionar el estado sin un Provider. Hasta que un día me surgió una pregunta. En la mayoría de las librerías del ecosistema React, envolver la aplicación con un Provider se ha convertido casi en un ritual. TanStack React Query exige envolverla con <code>QueryClientProvider</code> para poder usar <code>useQuery</code>, y overlay-kit de toss también exige <code>OverlayProvider</code> para poder llamar a <code>overlay.open()</code>. La Context API de React también requiere envolver el árbol de componentes con un Provider. Entonces, ¿qué clase de magia hace Zustand para no necesitar ese proceso?</p>
<p>Movido por la curiosidad, examiné directamente el código fuente de Zustand y encontré una estructura más interesante de lo que esperaba. En este artículo voy a ordenar lo que descubrí durante el proceso.</p>
<h2 id="cómo-fluye-el-estado-en-react"><a class="anchor" href="#cómo-fluye-el-estado-en-react">Cómo fluye el estado en React</a></h2>
<p>En una aplicación React convencional, el estado funciona como se muestra en la siguiente imagen.</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="Diagrama de flujo de datos en React: los datos bajan por props y los eventos suben" width="880" height="509" loading="eager" fetchpriority="high" decoding="async"></picture></p>
<p>El estado interno de un componente se gestiona con los hooks de gestión de estado que ofrece React (<code>useState</code>, <code>useReducer</code>). Después, el estado se transmite a los componentes hijos mediante props. Hasta aquí, la historia es sencilla.</p>
<p>El problema aparece cuando hay que compartir estado entre componentes muy alejados. La solución oficial que ofrece React en este caso es la Context API, pero esta exige envolver el subárbol con un componente Provider.</p>
<h3 id="por-qué-la-context-api-necesita-un-provider"><a class="anchor" href="#por-qué-la-context-api-necesita-un-provider">¿Por qué la Context API necesita un Provider?</a></h3>
<p>Para responder a esta pregunta, tenemos que observar brevemente el funcionamiento interno de React.</p>
<p>React gestiona el árbol de componentes mediante una estructura de datos interna llamada Fiber. Cada nodo Fiber está conectado mediante relaciones padre-hijo y, cuando cambia el valor de un Context, React recorre el árbol Fiber de arriba abajo, encuentra los componentes suscritos a ese Context y activa su rerenderizado.</p>
<p>La clave es esta: <strong>la propagación del valor de Context depende de la estructura del árbol Fiber.</strong> La posición del Provider en el árbol determina el alcance al que se transmite el valor, y el componente que llama a <code>useContext</code> asciende por su árbol Fiber para encontrar el Provider más cercano. ¿Y si no hay Provider? Simplemente se usa el valor predeterminado pasado a <code>createContext</code>.</p>
<p>Es decir, la Context API está estrechamente acoplada al sistema de renderizado de React. El almacenamiento, la propagación y la suscripción del estado ocurren dentro del árbol de componentes de React.</p>
<p>Entonces, ¿cómo evita Zustand esta estructura?</p>
<h2 id="zustand-vive-fuera-de-react"><a class="anchor" href="#zustand-vive-fuera-de-react">Zustand vive fuera de 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="Diagrama del patrón Flux: Action, Dispatcher, Store y View conectados en un solo sentido" width="1300" height="393" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<p>Zustand funciona sobre el patrón Flux. El <code>state</code> dentro del closure desempeña el papel de Store; las funciones definidas por el usuario, el de Actions; la función <code>set</code>, el de Dispatcher; y los componentes React, el de Views. Aquí aparece la diferencia decisiva.</p>
<p><strong>El Store de Zustand existe fuera del árbol de componentes de React, dentro del scope de un módulo JavaScript.</strong> Decir que está fuera del árbol de componentes significa que, a diferencia del estado interno de React, el estado de Zustand existe de forma independiente al árbol Fiber de React. Cualquier componente puede acceder al Store con solo hacer <code>import</code>, sin necesidad de envolver la aplicación en un Provider. (Es accesible desde cualquier lugar como una variable global, pero queda bien protegido dentro de un closure.)</p>
<p>¿Cómo es posible? Veamos el siguiente código.</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>En este código, <code>create</code> se llama cuando se carga el módulo. Es decir, el Store ya existe en memoria incluso antes de que React empiece a renderizar. Este es el patrón <strong>module-level singleton</strong>.</p>
<h3 id="qué-es-un-module-level-singleton"><a class="anchor" href="#qué-es-un-module-level-singleton">¿Qué es un module-level singleton?</a></h3>
<p>El sistema de módulos ES de JavaScript <strong>evalúa cada módulo una sola vez y almacena el resultado en caché</strong>. A partir de ahí, cualquier <code>import</code> del mismo módulo devuelve el mismo objeto almacenado, en lugar de volver a ejecutarlo. Es decir, tanto si el componente A hace <code>import { useStore } from './store'</code> como si lo hace el componente B, ambos hacen referencia a <strong>exactamente la misma instancia del Store</strong>.</p>
<p>No hace falta implementar una clase singleton aparte ni vincular nada a una variable global (<code>window.store</code>). El propio sistema de módulos satisface de forma natural las condiciones de un singleton: «se crea una sola vez y desde cualquier lugar se accede a la misma instancia». Zustand aprovecha directamente esta garantía del lenguaje para que todos los componentes puedan compartir un único Store sin un Provider adicional.</p>
<p>Llegados a este punto, surge una pregunta de forma natural: ¿cómo es exactamente Zustand por dentro?</p>
<h2 id="estructura-interna-de-zustand"><a class="anchor" href="#estructura-interna-de-zustand">Estructura interna de Zustand</a></h2>
<p>Al examinar el <a href="https://github.com/pmndrs/zustand/tree/main/src" target="_blank" rel="noopener noreferrer">repositorio de Zustand en GitHub</a>, sorprende lo concisa que es su lógica principal. Dos archivos concentran el núcleo: <code>vanilla.ts</code> contiene el Store propiamente dicho y <code>react.ts</code> se encarga de conectarlo con 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> es el corazón de Zustand. Todo lo relativo a cómo se crea el Store y cómo se gestiona el estado está contenido en este único archivo. Dicho de forma más sencilla, aquí se definen el estado encerrado en un closure y las funciones que lo manipulan.</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>Al analizar este código línea por línea, se revela el mecanismo central de Zustand.</p>
<h4 id="encapsulación-del-estado-mediante-un-closure"><a class="anchor" href="#encapsulación-del-estado-mediante-un-closure">Encapsulación del estado mediante un closure</a></h4>
<ul>
<li>
<p>La variable <code>let state: TState</code> se declara como variable local de la función <code>createStoreImpl</code>. Aunque la ejecución de la función termine, las funciones internas como <code>setState</code> y <code>getState</code> siguen haciendo referencia a esta variable, por lo que el recolector de basura no la elimina. Esa es la esencia de un closure.</p>
</li>
<li>
<p>Desde el exterior no existe ninguna forma de acceder directamente a la variable <code>state</code>. Solo se puede leer con <code>getState()</code> y escribir con <code>setState()</code>. (Es como implementar mediante un closure el campo private de la programación orientada a objetos.)</p>
</li>
</ul>
<h4 id="detección-de-cambios-con-objectis"><a class="anchor" href="#detección-de-cambios-con-objectis">Detección de cambios con <code>Object.is</code></a></h4>
<ul>
<li>
<p>Después de calcular el nuevo estado, <code>setState</code> lo compara con el estado anterior mediante <code>Object.is(nextState, state)</code>. Si la referencia es la misma, no ocurre nada. Esta es la primera línea de defensa contra rerenderizados innecesarios.</p>
</li>
<li>
<p>Sin embargo, esta comparación con <code>Object.is</code> comprueba la <strong>igualdad estricta de referencias (strict reference equality)</strong>, así que hay un aspecto al que debe prestar atención quien lo usa. No hay problema cuando se extrae un único valor primitivo, como un número o una cadena.</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>Pero la situación cambia si el selector <strong>devuelve un objeto nuevo</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>El objeto <code>{ count, name }</code> obtiene una referencia nueva en cada llamada, aunque sus valores sean idénticos. Como <code>Object.is</code> no compara las propiedades internas, sino solo las referencias, Zustand considera que «el estado ha cambiado» y activa un rerenderizado cada vez.</p>
<p>Para resolver este problema, Zustand ofrece el hook <strong><code>useShallow</code></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">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> compara una por una las <strong>propiedades de primer nivel del objeto devuelto</strong> y solo provoca un rerenderizado cuando los valores cambian realmente. Es un enfoque parecido al de <code>useSelector</code> de Redux, que utiliza comparación por referencia de forma predeterminada, pero permite pasar <code>shallowEqual</code> como segundo argumento. (Eso sí, como indica su nombre, <code>useShallow</code> hace una comparación «superficial», por lo que no sigue el interior de objetos anidados.)</p>
</li>
</ul>
<h4 id="sistema-de-listeners-con-el-patrón-pubsub"><a class="anchor" href="#sistema-de-listeners-con-el-patrón-pubsub">Sistema de listeners con el patrón Pub/Sub</a></h4>
<ul>
<li>La línea <code>const listeners: Set&#x3C;Listener> = new Set()</code> constituye todo el sistema de suscripción de Zustand. Cuando cambia el estado, <code>listeners.forEach</code> notifica a todos los suscriptores.</li>
<li>Al llamar a <code>subscribe</code>, el listener se añade al <code>Set</code>; al llamar a la función devuelta, se elimina del <code>Set</code>.</li>
<li>Este patrón es importante porque constituye un <strong>sistema de notificación completamente independiente del árbol Fiber de React</strong>. En lugar de que un Provider recorra el árbol buscando suscriptores, el propio Store administra directamente su lista de suscriptores.</li>
</ul>
<h4 id="creación-del-estado-inicial"><a class="anchor" href="#creación-del-estado-inicial">Creación del estado inicial</a></h4>
<ul>
<li>
<p>Veamos la última línea que gestiona el estado inicial.</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>Esta línea condensa muchas cosas. En JavaScript, el operador de asignación (<code>=</code>) es una expresión (expression) que <strong>devuelve el propio valor asignado</strong>. Por tanto, primero se ejecuta <code>state = createState(...)</code> dentro de los paréntesis y se asigna el estado inicial a <code>state</code>; después, el valor devuelto vuelve a asignarse a <code>const initialState</code>. Como resultado, <code>state</code> e <code>initialState</code> <strong>hacen referencia al mismo objeto</strong>.</p>
<p>Pero ¿por qué guardar deliberadamente el mismo valor en dos variables? La clave es que las dos variables tienen funciones distintas.</p>
<ul>
<li><strong><code>state</code></strong> es una variable declarada con <code>let</code>. Cada vez que se llama a <code>setState</code>, se sustituye por un valor nuevo. Representa, por tanto, <strong>el estado vivo en el momento actual</strong>.</li>
<li><strong><code>initialState</code></strong> es una variable declarada con <code>const</code>. Conserva permanentemente el estado que existía cuando se creó el Store. Ninguna llamada posterior a <code>setState</code> modifica este valor. Es <strong>la primera snapshot del Store</strong>.</li>
</ul>
<p>Este <code>initialState</code> se expone al exterior mediante el método <code>getInitialState()</code> y se pasa en <code>react.ts</code> como <strong>tercer argumento de <code>useSyncExternalStore</code> (snapshot del servidor)</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:#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>En un entorno de renderizado del lado del servidor (SSR) no existen las API del navegador ni la interacción del usuario, así que <code>setState</code> nunca llega a llamarse. Por eso, en el servidor siempre se utiliza <code>initialState</code> (= el estado inicial) como snapshot. Cuando empieza la hydration en el cliente, React compara el HTML renderizado en el servidor con el resultado del primer renderizado del cliente. Como ambos se han renderizado a partir del mismo <code>initialState</code>, se puede <strong>evitar un desajuste de hydration</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> se encarga de conectar el Store JavaScript puro que acabamos de crear con el sistema de renderizado de React.</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>La pieza central aquí es <code>useSyncExternalStore</code>. Este hook se introdujo en React 18 y fue diseñado para <strong>integrar de forma segura en el ciclo de renderizado de React un almacén de estado que existe fuera de React</strong>.</p>
<p>La estructura queda clara al observar los tres argumentos que recibe <code>useSyncExternalStore</code>. (Es casi lo mismo que vimos antes en vanilla.ts.)</p>
<ul>
<li><strong><code>api.subscribe</code></strong>: función que se suscribe a los cambios del Store. React la utiliza para pedir «avísame cuando cambie el estado».</li>
<li><strong><code>() => selector(api.getState())</code></strong>: devuelve la snapshot del estado actual. React llama a esta función en cada renderizado para obtener el estado más reciente.</li>
<li><strong><code>() => selector(api.getInitialState())</code></strong>: snapshot inicial que se usará durante el renderizado del lado del servidor. Evita discrepancias de estado entre el servidor y el cliente durante la hydration.</li>
</ul>
<p>En particular, <code>useSyncExternalStore</code> resuelve el <strong>problema de tearing</strong> que puede producirse en el modo concurrente de React (Concurrent Mode). El tearing ocurre cuando, dentro de una misma pasada de renderizado, componentes distintos muestran <strong>snapshots diferentes de la misma fuente de datos</strong>.</p>
<p>Resulta más fácil de entender con un escenario concreto. El componente A lee <code>store.value</code> (= 10) y empieza a renderizar. En ese momento, React <strong>pausa temporalmente (yield)</strong> el renderizado en modo concurrente y cede el control al navegador. Durante esa pausa llega un mensaje de WebSocket que cambia <code>store.value</code> a 11. Cuando React reanuda el renderizado, el componente B lee <code>store.value</code> (= 11). Como resultado, en el mismo frame A muestra 10 y B muestra 11, creando una <strong>UI desgarrada (teared)</strong>. Antes de React 18, el renderizado siempre era síncrono, por lo que este problema no se producía.</p>
<p><code>useSyncExternalStore</code> registra la snapshot existente al comenzar el renderizado (<code>getSnapshot</code>). Si el Store externo cambia durante el renderizado y la snapshot deja de coincidir, lo detecta y <strong>reinicia el renderizado desde el principio</strong>. Así garantiza que todos los componentes se rendericen a partir de la misma snapshot.</p>
<p>Por último, la función <code>createImpl</code> reúne todo esto.</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>Se crea un Store vanilla con <code>createStore</code>, se envuelve en un hook personalizado llamado <code>useBoundStore</code> y, mediante <code>Object.assign</code>, se adjuntan los métodos de la API del Store (<code>setState</code>, <code>getState</code>, <code>subscribe</code>, etc.) a la propia función hook. Como resultado, el <code>useBoundStore</code> devuelto posee una doble naturaleza: <strong>es un hook de React y, al mismo tiempo, la API del Store</strong>. (Un patrón muy propio de JavaScript: una función que también tiene métodos.)</p>
<h2 id="qué-ocurre-con-otras-librerías-de-gestión-de-estado"><a class="anchor" href="#qué-ocurre-con-otras-librerías-de-gestión-de-estado">¿Qué ocurre con otras librerías de gestión de estado?</a></h2>
<p>Después de entender todo esto, es natural querer compararlo con otras librerías.</p>
<p>Existen muchas librerías de gestión de estado, como Jotai, Recoil, MobX, Xstate y Redux, pero me centraré en las que he utilizado personalmente.</p>
<blockquote>
<p>Como referencia, <strong>Recoil</strong> (Meta), que solía compararse a menudo con Jotai, archivó su repositorio en enero de 2025 y su desarrollo quedó, en la práctica, interrumpido. Tampoco llegó a incorporar compatibilidad con React 19. Si se busca un modelo de estado atómico, hoy Jotai puede considerarse la única opción realista.</p>
</blockquote>
<h3 id="redux"><a class="anchor" href="#redux">Redux</a></h3>
<p>Redux también utiliza internamente un Store a nivel de módulo. Entonces, ¿por qué necesita un Provider?</p>
<p>El <code>&#x3C;Provider store={store}></code> de Redux <strong>inyecta (inject)</strong> la instancia del Store en el árbol de componentes mediante React Context. <code>useSelector</code> y <code>useDispatch</code> llaman internamente a <code>useContext</code> para acceder al Store ofrecido por el Provider. Lo importante aquí es que Redux no usa Context como <strong>canal de propagación del estado, sino como mecanismo de inyección de dependencias (Dependency Injection)</strong>. Lo que se transmite mediante Context no es el propio valor del estado, sino <strong>una referencia al objeto Store</strong> que administra ese estado. La suscripción y las actualizaciones reales del estado se procesan con el Pub/Sub interno del Store.</p>
<p>Las ventajas de este diseño son claras. Durante las pruebas, envolver una instancia distinta del Store con un Provider ofrece un aislamiento perfecto; además, una misma aplicación puede construir varios árboles de Store independientes mediante la prop <code>context</code>. Como subraya Mark Erikson, mantenedor de Redux, «Context es un mecanismo de transporte (transport mechanism), no una herramienta de gestión de estado».</p>
<h3 id="jotai"><a class="anchor" href="#jotai">Jotai</a></h3>
<p>Jotai adopta un <strong>modelo de estado atómico (atomic)</strong> radicalmente distinto del de Redux o Zustand. En lugar de reunir todo el estado en un gran objeto Store, este enfoque <strong>separa cada fragmento de estado en un atom independiente</strong>. (La propia documentación oficial de Jotai explica que «si Zustand se parece a Redux, Jotai se parece a Recoil».)</p>
<p>La diferencia central de esta estructura está en <strong>cómo optimiza el renderizado</strong>. Zustand sigue un enfoque <strong>descendente (top-down)</strong> que extrae mediante un selector solo la parte necesaria de un único Store. El desarrollador debe escribir directamente un selector como <code>useStore((state) => state.count)</code> y, en ocasiones, necesita memoización para conservar la igualdad referencial (referential equality). Jotai, por el contrario, crea automáticamente un <strong>grafo de dependencias (dependency graph)</strong> entre atoms. Cuando cambia uno, propaga el cambio <strong>de abajo arriba (bottom-up)</strong> y rerenderiza exactamente los componentes que dependen de ese atom. Este seguimiento automático de dependencias resulta especialmente eficaz cuando decenas de estados están interrelacionados, como en una hoja de cálculo o un editor de canvas.</p>
<p>Desde el punto de vista del Provider, Jotai ocupa una posición intermedia interesante. De forma predeterminada utiliza un Store global y funciona sin Provider, pero, si hace falta, puede envolverse con <code>&#x3C;Provider></code> para crear un scope de Store aislado. Tomando prestadas las palabras de la documentación oficial de Jotai, Jotai es <strong>«context first, module second»</strong>, mientras que Zustand es <strong>«module first, context second»</strong>.</p>
<h3 id="la-elección-de-zustand"><a class="anchor" href="#la-elección-de-zustand">La elección de Zustand</a></h3>
<p>Zustand tomó la decisión más radical. De forma predeterminada es un singleton a nivel de módulo y no tiene ningún Provider. Lo que aporta esta elección es una <strong>API extremadamente sencilla</strong>. Basta con crear el Store mediante <code>create</code> y llamar al hook desde el componente.</p>
<p>Sin embargo, decir que «no tiene ningún Provider» describe, para ser exactos, su <strong>diseño predeterminado</strong>. Desde v4 se puede implementar el patrón <strong>Scoped Store</strong> combinando <code>createStore</code> (un Store vanilla) con el <code>createContext</code> de React.</p>
<p>El <a href="https://tkdodo.eu/blog/zustand-and-react-context" target="_blank" rel="noopener noreferrer">blog de TkDodo, mantenedor de React Query</a>, analiza este patrón en profundidad. Su argumento principal es que un Store singleton global tiene tres limitaciones.</p>
<ul>
<li><strong>No se puede inicializar con props</strong>: como el Store se crea al cargar el módulo, no hay forma de usar como valor inicial los datos recibidos del servidor o las props del componente padre.</li>
<li><strong>El aislamiento de las pruebas es difícil</strong>: hay que restablecer manualmente el Store en cada prueba.</li>
<li><strong>No es reutilizable</strong>: si se renderizan en una página dos componentes que necesitan un Store con la misma estructura, ambos terminan compartiendo el estado.</li>
</ul>
<p>El patrón Scoped Store resuelve las tres limitaciones. La idea central es <strong>transmitir mediante Context la referencia a la instancia del Store, no el valor del estado</strong>. (Es exactamente la misma estructura que utiliza el Provider de Redux.)</p>
<p>La implementación concreta es la siguiente.</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>Ahora se pueden renderizar en una misma página tantos componentes multiselect independientes como se quiera.</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>Hay que destacar que lo que se transmite mediante Context <strong>no es el valor del estado, sino el objeto Store</strong>. Aunque cambie el valor del estado, el <code>value</code> de Context (= la referencia al Store) no cambia, por lo que <strong>no se producen rerenderizados innecesarios debidos a un cambio del valor de Context.</strong> El rerenderizado real se gestiona dentro de <code>useStore</code>, donde <code>useSyncExternalStore</code> aplica el selector. La función de transporte de Context queda perfectamente separada de la función de suscripción de Zustand.</p>
<p>TkDodo presentó un caso real en el que aplicó este patrón a un componente multiselect de un sistema de diseño. La estructura anterior, que gestionaba el estado interno con <code>useState</code> + Context, sufría una degradación del rendimiento con más de 50 elementos. El problema se resolvió al pasar a la suscripción basada en selectors de Zustand.</p>
<p>Después de que en v4 se eliminara el helper que v3 ofrecía mediante <code>zustand/context</code>, llamado <code>createContext</code>, este patrón se consolidó como la <strong>combinación directa del <code>createContext</code> nativo de React con <code>createStore</code>/<code>useStore</code> de Zustand</strong>. La API sigue igual en v5, y la <a href="https://github.com/pmndrs/zustand/blob/main/docs/previous-versions/zustand-v3-create-context.md" target="_blank" rel="noopener noreferrer">documentación oficial de Zustand</a> también presenta este patrón en la guía de migración a v4+.</p>
<h2 id="la-sombra-de-providerless"><a class="anchor" href="#la-sombra-de-providerless">La sombra de ProviderLess</a></h2>
<p>Por supuesto, la ausencia de un Provider no solo ofrece ventajas. Voy a resumir los aspectos a los que, en mi opinión, conviene prestar atención.</p>
<h3 id="el-problema-de-compartir-estado-en-ssr"><a class="anchor" href="#el-problema-de-compartir-estado-en-ssr">El problema de compartir estado en SSR</a></h3>
<p>Un singleton a nivel de módulo puede ser peligroso en un entorno de servidor. Un servidor Node.js procesa varias solicitudes en un único proceso, mientras que cada módulo solo se carga una vez dentro de ese proceso. Esto significa que las solicitudes de usuarios distintos podrían <strong>compartir la misma instancia del Store</strong>.</p>
<p>Por eso Zustand ofrece <code>getInitialState</code> y pasa una snapshot del servidor como tercer argumento de <code>useSyncExternalStore</code>. Sin embargo, esto por sí solo puede no aislar por completo el estado entre solicitudes. En entornos SSR se recomienda usar el patrón Scoped Store mencionado antes (<code>createStore</code> + React Context) para crear un Store nuevo en cada solicitud.</p>
<h3 id="la-dificultad-de-aislar-las-pruebas"><a class="anchor" href="#la-dificultad-de-aislar-las-pruebas">La dificultad de aislar las pruebas</a></h3>
<p>En una librería basada en Provider, envolver cada prueba con un Provider distinto aísla el Store de forma natural. En cambio, el singleton a nivel de módulo de Zustand puede filtrar estado entre pruebas. Por eso hay que restablecer explícitamente el Store en el <code>beforeEach</code> de cada prueba. (Yo también sufrí este problema una vez.)</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>Aquí también el patrón Scoped Store sirve como solución. Si se envuelve con un Provider, cada prueba puede crear e inyectar un Store nuevo, lo que permite un aislamiento perfecto sin lógica de restablecimiento.</p>
<h3 id="la-ausencia-de-múltiples-instancias"><a class="anchor" href="#la-ausencia-de-múltiples-instancias">La ausencia de múltiples instancias</a></h3>
<p>Si una aplicación necesita dos Stores independientes con la misma estructura, con el patrón Provider basta con envolver cada uno en un Provider diferente. Pero con un singleton a nivel de módulo hay que llamar por separado a la función de creación del Store para obtener instancias distintas. Por ejemplo, si una misma página contiene dos paneles de pestañas independientes y cada uno debe gestionar por separado su estado de selección, resulta difícil expresarlo de forma natural mediante un singleton global.</p>
<p>También en este caso, el patrón <code>createStore</code> + Context es la respuesta. Si cada componente de panel renderiza su propio Provider, se crean instancias totalmente independientes con la misma estructura de Store. La documentación oficial de Zustand recomienda este patrón cuando «un componente reutilizable necesita un Store».</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>En resumen, el diseño ProviderLess de Zustand es posible gracias a la combinación de los cuatro mecanismos siguientes.</p>
<ul>
<li><strong>Singleton a nivel de módulo</strong>: el Store se crea fuera del árbol de componentes de React, dentro del scope de un módulo JavaScript.</li>
<li><strong>Encapsulación del estado mediante un closure</strong>: en <code>vanilla.ts</code>, dentro de <code>createStoreImpl</code>, la variable <code>state</code> y el Set <code>listeners</code> quedan encerrados en un closure e inaccesibles desde el exterior.</li>
<li><strong>Sistema Pub/Sub propio</strong>: en lugar de recorrer el árbol Fiber, gestiona directamente <code>Set&#x3C;Listener></code> para notificar los cambios de estado a los suscriptores.</li>
<li><strong>Integración con React mediante <code>useSyncExternalStore</code></strong>: sincroniza de forma segura los cambios de estado del Store externo con el ciclo de renderizado de React.</li>
</ul>
<p>Al final, la pregunta que plantea Zustand es esta: «¿Tiene el estado que vivir necesariamente dentro de React?». La respuesta de Zustand es clara. El estado puede estar fuera de React y solo hace falta tender un puente cuando sea necesario. Ese puente es <code>useSyncExternalStore</code>.</p>
<p>Por supuesto, este enfoque no es el mejor en todas las situaciones. En escenarios como SSR, aislamiento de pruebas o múltiples instancias, un diseño basado en Provider puede ser más adecuado. No hay una única respuesta correcta, pero entender los trade-offs de diseño que ha elegido cada librería permite escoger la herramienta adecuada para cada situación.</p>
<p>Recomiendo a quienes lean este artículo que abran alguna vez el código fuente de una de las librerías que utilizan. Es posible descubrir una profundidad que no aparece en la documentación oficial.</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="Un personaje dibujado a mano que dice &#x22;¡un momento!&#x22;" width="216" height="233" loading="lazy" fetchpriority="low" decoding="async"></picture></p>
<h3 id="ah-y-una-novedad"><a class="anchor" href="#ah-y-una-novedad">Ah, y una novedad</a></h3>
<p>Mientras investigaba lo anterior, descubrí que <strong>Zustand v5.0.0 se lanzó oficialmente en octubre de 2024</strong>.</p>
<p>Lo interesante es que v5 apenas incorpora funciones nuevas. Durante v4.x ya se habían añadido nuevas funciones y se habían marcado como deprecated varias API existentes, por lo que v5 tiene sobre todo el carácter de una <strong>versión de limpieza (cleanup)</strong>. Estos son los principales cambios. (Para más detalles, consulta la <strong><a href="https://github.com/pmndrs/zustand/releases" target="_blank" rel="noopener noreferrer">página de releases</a></strong> y la <strong><a href="https://zustand.docs.pmnd.rs/reference/migrations/migrating-to-v5" target="_blank" rel="noopener noreferrer">guía de migración</a></strong>.)</p>
<ul>
<li>Los requisitos mínimos aumentaron a <strong>React 18 y TypeScript 4.5 o superior</strong>.</li>
<li>Se eliminó <strong><code>getServerState</code></strong>. (Se sustituye por el tercer argumento de <code>useSyncExternalStore</code>.)</li>
<li>Se dejó de ofrecer <strong>compatibilidad con ES5</strong>.</li>
<li>Se eliminó la posibilidad de indicar una <strong>función equality personalizada</strong> en la función <code>create</code>.</li>
<li>Se mejoró la función <strong><code>shallow</code> para admitir objetos iterables</strong>.</li>
</ul>
<p>Al migrar de v4 a v5, se recomienda actualizar primero a la versión más reciente de v4. Esa versión muestra advertencias de deprecation; si se resuelven antes de pasar a v5, la transición puede realizarse sin dificultades.</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[Cómo funcionan los algoritmos de compresión]]></title>
            <link>https://hooninedev.com/es/240706</link>
            <guid isPermaLink="false">https://hooninedev.com/es/240706</guid>
            <pubDate>Sat, 06 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar sobre los algoritmos de compresión de software. Este artículo es para desarrolladores que suben artefactos de compilación grandes a un servidor o almacenamiento y dudan ...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar sobre los algoritmos de compresión de software.</p>
<p>Este artículo es para desarrolladores que suben artefactos de compilación grandes a un servidor o almacenamiento y dudan entre zip, tar.gz y tar.zst. Al terminar, sabrás en qué se diferencian los principales formatos en velocidad y tasa de compresión, y tendrás criterios para elegir entre tar.gz y tar.zst.</p>
<p>Me encargaron mejorar el proceso de despliegue de un proyecto interno. La arquitectura exigía subir a S3 artefactos de compilación muy grandes, y pude comprobar que el tamaño de la carpeta de build afectaba directamente al tiempo de subida y al coste de almacenamiento. De ahí surgió una pregunta natural: ¿cómo podíamos comprimir y subir esos archivos de forma más eficiente?</p>
<p>Al investigar descubrí muchos más formatos de los que esperaba: zip, gzip, zstd, bzip2, xz y otros. Sus nombres se parecían, pero no era fácil encontrar una explicación que aclarara sus diferencias y cuándo convenía usar cada uno. (Pensaba que toda compresión era más o menos igual, pero el mundo es grande y las formas de reducir archivos también.)</p>
<p>Antes de comparar, conviene señalar una raíz común. Casi todos los compresores de uso general actuales descienden de LZ77, publicado en 1977. LZ77 usa como diccionario un tramo de los datos ya procesados, la ventana deslizante, y sustituye los patrones repetidos por una referencia breve que significa “retrocede tantos caracteres y copia tantos”. Una ventana más grande detecta repeticiones más alejadas, pero exige más cómputo y memoria. DEFLATE, el algoritmo de ZIP y GZIP, añade a esto la codificación Huffman (una codificación entrópica que asigna secuencias de bits más cortas a los símbolos más frecuentes). Cómo se separó LZ77 de su hermano LZ78 lo explico aparte en <a href="/240701" target="_blank" rel="noopener noreferrer">La diferencia entre LZ77 y LZ78</a>.</p>
<p>El rendimiento de compresión se evalúa en dos ejes: la <strong>tasa de compresión</strong>, cuánto se reduce el archivo, y la <strong>velocidad de compresión</strong>, cuánto tarda el proceso. Perseguir una tasa más alta suele requerir más cómputo y más tiempo. La estrategia práctica consiste en encontrar el punto adecuado entre ambos.</p>
<p>Con esta base, comparemos los distintos formatos uno por uno.</p>
<h2 id="zip"><a class="anchor" href="#zip">ZIP</a></h2>
<p>ZIP es un formato creado por Phil Katz en 1989. En su interior suele usar <strong>DEFLATE</strong>, que combina LZ77 y Huffman coding. La distinción importante es que ZIP no es un algoritmo de compresión, sino un formato contenedor que almacena datos comprimidos mediante algoritmos como DEFLATE.</p>
<p>ZIP <strong>comprime cada archivo por separado</strong>. Esta estructura, en la que cada archivo se comprime de forma independiente, se conoce como archivo no sólido (Non-solid Archive). El enfoque opuesto, que une todos los archivos en un solo flujo y lo comprime de una vez, es el archivo sólido. Gracias a la estructura no sólida, es posible extraer un archivo concreto sin descomprimir los demás. Como contrapartida, no aprovecha datos repetidos entre archivos, por lo que puede obtener una tasa inferior a tar.gz, que veremos más adelante.</p>
<p>Windows, macOS, Linux y la mayoría de los sistemas operativos lo admiten sin instalar software adicional. Por eso es la opción más segura cuando importa la compatibilidad entre plataformas.</p>
<h2 id="gzip-gnu-zip"><a class="anchor" href="#gzip-gnu-zip">GZIP (GNU Zip)</a></h2>
<p>Al igual que ZIP, GZIP utiliza <strong>DEFLATE</strong> internamente. ¿Por qué existe otro formato si el algoritmo es el mismo? ZIP también actúa como contenedor para varios archivos, mientras que GZIP está especializado en comprimir <strong>un único archivo o flujo</strong>.</p>
<p>Para comprimir varios archivos o un directorio con GZIP, primero se reúnen en un archivo TAR y después se comprime ese archivo con GZIP. Este proceso de dos pasos genera un <code>.tar.gz</code> o <code>.tgz</code>.</p>
<p>La estructura de GZIP, especificada en RFC 1952, es bastante sencilla: una <strong>cabecera fija de 10 bytes</strong>, una cabecera extendida opcional con datos como el nombre original o comentarios, los datos DEFLATE y un <strong>tráiler de 8 bytes</strong> con la suma CRC-32 y el tamaño original. CRC-32 permite comprobar que los datos descomprimidos coinciden con el original. GZIP es, por tanto, una envoltura ligera alrededor de un flujo DEFLATE.</p>
<p>DEFLATE usa una ventana deslizante de <strong>hasta 32 KB</strong>. Ese tamaño limita la tasa de compresión porque no se pueden referenciar patrones separados por más de 32 KB. GZIP también ofrece niveles del 1 al 9. El nivel 1 es rápido, pero obtiene una tasa menor, alrededor del 60%; el nivel 9 es lento, pero alcanza aproximadamente el 75%. El valor predeterminado, 6, busca el equilibrio entre velocidad y tamaño.</p>
<p>En entornos Unix y Linux se utiliza como un estándar para distribuir código fuente, comprimir registros y empaquetar software. También sigue siendo una opción habitual para compresión HTTP mediante <code>Content-Encoding: gzip</code>, aunque Brotli lo está sustituyendo progresivamente en ese terreno.</p>
<h2 id="zstd-zstandard"><a class="anchor" href="#zstd-zstandard">ZSTD (Zstandard)</a></h2>
<p>ZSTD es un algoritmo desarrollado por Yann Collet en Meta, antes Facebook, y publicado como código abierto en 2016. Su gran ventaja es que <strong>comprime y descomprime mucho más rápido, manteniendo una tasa comparable a GZIP</strong>.</p>
<p>Su funcionamiento interno tiene tres grandes etapas. Primero, un <strong>buscador de coincidencias (Match Finder)</strong> de la familia LZ77 detecta patrones repetidos. Después codifica los literales, las longitudes y los desplazamientos encontrados como <strong>secuencias</strong>. Por último, comprime esas secuencias mediante <strong>codificación entrópica</strong>. En lugar de la codificación Huffman de GZIP, utiliza <strong>FSE (Finite State Entropy)</strong>, un codificador basado en ANS (Asymmetric Numeral Systems). ANS actualiza un único estado entero cada vez que procesa un símbolo y emite menos bits cuanto más frecuente es el símbolo. Su objetivo es lograr una tasa de compresión cercana a la de la codificación aritmética (Arithmetic Coding) con una velocidad cercana a la de Huffman. Huffman solo puede asignar un número entero de bits por símbolo; FSE puede representar probabilidades equivalentes a bits fraccionarios y acercarse más al límite teórico. (A pesar del nombre, la idea esencial es expresar los mismos datos con menos bits de una manera más inteligente.)</p>
<p>El buscador también cambia de estrategia según el nivel. Los niveles bajos, del 1 al 4, usan tablas hash sencillas para ganar velocidad. Los intermedios, del 5 al 12, comparan varios candidatos mediante una estrategia Lazy. Los altos, del 13 al 22, recurren a árboles binarios y programación dinámica para encontrar coincidencias casi óptimas. Esta escala permite usar niveles bajos para transmisión en tiempo real y altos para archivo.</p>
<p>En el benchmark Silesia Corpus, ZSTD en su nivel predeterminado 3 comprime a unos 300 MB/s y descomprime a unos 1.200 MB/s. GZIP en el nivel 6 alcanza apenas 34 MB/s y 380 MB/s, respectivamente. <strong>ZSTD comprime unas ocho veces más rápido y descomprime unas tres, y aun así su tasa es superior: 3,17 frente a 3,09 de GZIP.</strong> Estas cifras muestran de forma directa cómo mejora el compromiso tradicional.</p>
<p>Su adopción crece con rapidez. Se usa para comprimir módulos del kernel de Linux y de forma transparente en sistemas de archivos; distribuciones como Arch Linux, Fedora, Debian y Ubuntu lo han adoptado como formato predeterminado de compresión de paquetes. Desde la versión 1.5.7, publicada en febrero de 2025, la <strong>compresión multihilo está activada de forma predeterminada</strong> con hasta cuatro hilos, ampliando aún más la diferencia práctica frente al GZIP monohilo. AWS también ha explicado que redujo alrededor de un 30% el almacenamiento en S3 al migrar servicios internos de gzip a zstd.</p>
<h2 id="bzip2"><a class="anchor" href="#bzip2">BZIP2</a></h2>
<p>BZIP2 comprime mediante una cadena de transformaciones.</p>
<ol>
<li><strong>RLE (Run-Length Encoding)</strong>: reduce las repeticiones consecutivas de los datos iniciales</li>
<li><strong>BWT (Burrows-Wheeler Transform)</strong>: reorganiza los datos para facilitar su compresión</li>
<li><strong>MTF (Move-to-Front Transform)</strong>: convierte la salida de BWT en una secuencia numérica</li>
<li><strong>RLE</strong>: vuelve a reducir las repeticiones del resultado de MTF</li>
<li><strong>Huffman Coding</strong>: aplica finalmente una codificación basada en frecuencias</li>
</ol>
<p>BZIP2 proporciona una tasa mayor que GZIP, pero tanto la compresión como la descompresión son más lentas. Se ha usado para archivo cuando el tamaño importaba más que la velocidad.</p>
<p>Su última versión fue la 1.0.8, publicada en 2019, y no está en desarrollo activo. A medida que los benchmarks muestran que ZSTD supera a BZIP2 en tasa y velocidad, los proyectos nuevos tienden a elegir ZSTD.</p>
<h2 id="xz"><a class="anchor" href="#xz">XZ</a></h2>
<p>XZ es un formato que usa <strong>LZMA2</strong>. LZMA, Lempel-Ziv-Markov chain Algorithm, fue desarrollado por Igor Pavlov y combina compresión por diccionario basada en LZ77 con codificación por rangos (Range Encoding). Más que una simple “versión mejorada de LZMA”, LZMA2 se parece a un <strong>formato contenedor</strong> para flujos LZMA. Añade compresión y descompresión multihilo y un tratamiento eficiente de los datos que no se pueden comprimir.</p>
<p>De todos los formatos tratados aquí, XZ ofrece <strong>la tasa de compresión más alta</strong>. A cambio, comprime muy despacio y consume mucha memoria. Resulta adecuado para archivo cuando ahorrar espacio es la prioridad absoluta.</p>
<p>En marzo de 2024, sin embargo, <strong>se descubrió una puerta trasera en xz-utils, la biblioteca central de XZ, durante el grave incidente de cadena de suministro CVE-2024-3094</strong>. Una campaña de ingeniería social de dos años había obtenido permisos de mantenimiento, y la vulnerabilidad recibió la puntuación máxima CVSS 10.0. Las principales distribuciones revirtieron inmediatamente a versiones seguras, pero el caso fue una advertencia contundente sobre la seguridad de la cadena de suministro de código abierto. (El valor técnico de XZ permanece, aunque conviene conocer este contexto al elegir herramientas.)</p>
<h2 id="tar"><a class="anchor" href="#tar">TAR</a></h2>
<p>TAR, Tape Archive, no es un algoritmo de compresión. Es una herramienta y un formato que <strong>reúne varios archivos y directorios en un solo archivo</strong>. Se creó originalmente para copias en cinta magnética. Dado que la cinta es un medio secuencial, concatenar los datos de forma continua era una estructura natural.</p>
<p>Su organización interna es sorprendentemente sencilla. Todo se procesa en <strong>bloques de 512 bytes</strong>. Antes de cada archivo aparece una cabecera de 512 bytes con metadatos como el nombre, hasta 100 bytes, el modo, UID/GID del propietario, tamaño, fecha de modificación y suma de comprobación. Los datos siguen a la cabecera y se rellenan hasta un múltiplo de 512 bytes. Dos bloques de ceros de 512 bytes indican el final. La mayoría de las implementaciones modernas siguen <strong>UStar (Unix Standard TAR)</strong>, definido por POSIX, que admite nombres de hasta 256 bytes y campos adicionales.</p>
<p>La propiedad clave es que TAR conserva <strong>los metadatos del sistema de archivos Unix</strong>, como permisos, propietarios, marcas de tiempo y enlaces simbólicos. ZIP no siempre conserva perfectamente estos datos específicos de Unix, por lo que TAR suele encajar mejor en despliegues de servidor.</p>
<p>TAR no reduce el tamaño por sí mismo; de hecho, las cabeceras y el relleno lo hacen ligeramente mayor que el original. La compresión real se consigue al combinarlo con GZIP, BZIP2, XZ o ZSTD. De ahí proceden extensiones como <code>.tar.gz</code>, <code>.tar.bz2</code>, <code>.tar.xz</code> y <code>.tar.zst</code>. TAR se encarga de “agrupar” y la otra herramienta de “reducir”, un ejemplo clásico de la filosofía Unix de “hacer bien una sola cosa”.</p>
<p>Es el método estándar de archivado en Unix/Linux, mientras que Windows puede necesitar software adicional como 7-Zip.</p>
<h2 id="una-breve-mirada-a-brotli"><a class="anchor" href="#una-breve-mirada-a-brotli">Una breve mirada a Brotli</a></h2>
<p>Quienes desarrollan frontend también deberían conocer <strong>Brotli</strong>. Google creó este algoritmo y en 2015 se estandarizó para compresión de flujos HTTP como <code>Content-Encoding: br</code>.</p>
<p>Todos los navegadores principales lo admiten sobre HTTPS, con una cobertura global superior al 96%, y suele ofrecer <strong>entre un 15 y un 25% más de compresión que GZIP</strong>. Resulta especialmente eficaz con archivos estáticos de texto como JavaScript, CSS y HTML. Grandes CDN como Cloudflare lo usan de forma predeterminada, y la práctica moderna se resume en “Brotli primero, GZIP como alternativa”.</p>
<p>Si los artefactos se suben a S3 y se sirven mediante una CDN, precomprimir los estáticos con Brotli puede reducir notablemente la transferencia de red. (En aquel momento no tenía suficiente evidencia específica del proyecto para introducirlo de inmediato, pero sigue siendo una opción que merece conocerse y revisarse.)</p>
<h2 id="por-qué-targz-comprime-mejor-que-zip"><a class="anchor" href="#por-qué-targz-comprime-mejor-que-zip">Por qué tar.gz comprime mejor que ZIP</a></h2>
<p>La razón está en la diferencia entre <strong>archivos sólidos (Solid Archive)</strong> y <strong>no sólidos (Non-solid Archive)</strong>.</p>
<p>Con tar.gz, TAR reúne todos los archivos en un flujo continuo y GZIP comprime el flujo entero de una vez. Así puede reconocer y aprovechar <strong>datos repetidos entre archivos</strong>. Eso es un archivo sólido. Si una carpeta de build contiene decenas de bundles JavaScript parecidos, un patrón del archivo A puede referenciarse cuando reaparece en B. También disminuye la sobrecarga porque no hay que registrar una cabecera, una suma y una tabla de contenidos independientes para cada flujo comprimido.</p>
<p>ZIP es no sólido y comprime cada archivo por separado, de modo que no aprovecha redundancias entre ellos. Aunque A y B contengan el mismo bloque de código, sus flujos DEFLATE no conocen la existencia del otro. Por eso tar.gz suele obtener una tasa entre un 5 y un 15% mejor que ZIP. La diferencia crece cuando el artefacto contiene muchos archivos de estructura similar.</p>
<p>Los archivos sólidos también tienen desventajas claras.</p>
<ul>
<li>Para extraer un solo archivo puede ser necesario <strong>descomprimir primero todos los datos que lo preceden</strong>. Como todo forma un único flujo, no se puede saltar directamente a un punto intermedio. ZIP permite acceso aleatorio a cada archivo y puede ser mejor cuando se extraen elementos concretos con frecuencia.</li>
<li>Si una parte se daña, <strong>todos los datos posteriores al punto dañado pueden quedar irrecuperables</strong>. En un formato no sólido a veces se pierde solo el archivo afectado y se conserva el resto.</li>
</ul>
<h2 id="en-2024-elegí-targz-qué-elegiría-ahora"><a class="anchor" href="#en-2024-elegí-targz-qué-elegiría-ahora">En 2024 elegí tar.gz. ¿Qué elegiría ahora?</a></h2>
<p>Añadí esta sección en 2026. Entonces escogí tar.gz por su compatibilidad y estabilidad. Tras subir el artefacto a S3 había que descomprimirlo en distintos entornos, así que un formato disponible casi en cualquier lugar era la opción segura.</p>
<p>Si afrontara hoy la misma situación, consideraría seriamente <strong>tar.zst (TAR + ZSTD)</strong>. Recordemos las cifras anteriores.</p>
<p>GZIP comprime a 34 MB/s en su nivel predeterminado y ZSTD a 300 MB/s. Para una carpeta de 2 GB, un cálculo simple da unos 60 segundos con GZIP y siete con ZSTD. Si además contamos el multihilo activado por defecto desde ZSTD v1.5.7, con hasta cuatro hilos, el tiempo real es todavía menor. En una canalización CI/CD esta diferencia se acumula en cada despliegue, así que no es una cifra despreciable.</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 también iguala o mejora la tasa de GZIP, por lo que prácticamente desaparece el compromiso de aceptar un archivo mayor para ganar velocidad. Es más rápido y produce un resultado más pequeño.</p>
<p>Aun así, hay que confirmar que el entorno receptor pueda descomprimir zstd. Las principales distribuciones Linux ya lo incluyen y en macOS se instala fácilmente con Homebrew mediante <code>brew install zstd</code>. Los sistemas antiguos o mínimos quizá necesiten una instalación adicional, por lo que conviene revisar de antemano todos los entornos del equipo. Si la compatibilidad es la prioridad absoluta, tar.gz sigue siendo la alternativa más segura.</p>
<h2 id="comparativa-rápida"><a class="anchor" href="#comparativa-rápida">Comparativa rápida</a></h2>
<table>
<thead>
<tr>
<th>Formato</th>
<th>Algoritmo</th>
<th>Tasa</th>
<th>Velocidad</th>
<th>Características principales</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>ZIP</strong></td>
<td>DEFLATE</td>
<td>Media</td>
<td>Rápida</td>
<td>Multiplataforma, no sólido</td>
</tr>
<tr>
<td><strong>GZIP</strong></td>
<td>DEFLATE</td>
<td>Media</td>
<td>Rápida</td>
<td>Flujo único, combinado con TAR</td>
</tr>
<tr>
<td><strong>ZSTD</strong></td>
<td>Zstandard</td>
<td>Alta</td>
<td>Muy rápida</td>
<td>Niveles ajustables, estándar moderno</td>
</tr>
<tr>
<td><strong>BZIP2</strong></td>
<td>BWT+MTF+Huffman</td>
<td>Alta</td>
<td>Lenta</td>
<td>Desarrollo prácticamente detenido</td>
</tr>
<tr>
<td><strong>XZ</strong></td>
<td>LZMA2</td>
<td>Muy alta</td>
<td>Muy lenta</td>
<td>Máxima tasa, contexto de seguridad</td>
</tr>
<tr>
<td><strong>Brotli</strong></td>
<td>Brotli</td>
<td>Alta</td>
<td>Media</td>
<td>Especializado en la web</td>
</tr>
</tbody>
</table>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>Antes de profundizar en la compresión pensaba sinceramente: “¿No basta con meterlo todo en un zip?”. Trabajar con una carpeta de build de más de 2 GB hizo tangible que el algoritmo elegido puede cambiar de forma significativa el tiempo de subida y el coste.</p>
<p>Cada formato tiene su propia filosofía y sus compromisos: la compatibilidad de ZIP, la universalidad de GZIP, la velocidad de ZSTD y la tasa de XZ. No existe una opción “mejor” en todos los casos; la decisión adecuada depende del contexto del proyecto.</p>
<p>Entender los principios de las herramientas que usamos sin pensar nos ayuda a decidir mejor cuando aparece un problema parecido. Espero que este artículo sirva como una pequeña referencia para quien tenga que elegir un algoritmo de compresión.</p>]]></content:encoded>
            <author>jihoon7705@gmail.com (이지훈)</author>
            <category>curiosidades</category>
            <category>software</category>
        </item>
        <item>
            <title><![CDATA[La diferencia entre LZ77 y LZ78]]></title>
            <link>https://hooninedev.com/es/240701</link>
            <guid isPermaLink="false">https://hooninedev.com/es/240701</guid>
            <pubDate>Mon, 01 Jul 2024 00:00:00 GMT</pubDate>
            <description><![CDATA[En este artículo quiero hablar de en qué se diferencian LZ77 y LZ78. Este artículo es para desarrolladores que usan herramientas como zip, gzip o zstd y se han preguntado cómo funciona la compresión b...]]></description>
            <content:encoded><![CDATA[<p>En este artículo quiero hablar de <strong>en qué se diferencian LZ77 y LZ78</strong>.</p>
<p>Este artículo es para desarrolladores que usan herramientas como zip, gzip o zstd y se han preguntado cómo funciona la compresión basada en diccionario que llevan dentro. Al terminar, podrás explicar en qué se diferencian ambos algoritmos al manejar su diccionario y de cuál de los dos descienden los compresores dominantes actuales.</p>
<p>Si se comparan formatos para comprimir artefactos de compilación, tarde o temprano se acaba remontando a estos dos algoritmos.</p>
<h2 id="compresión-basada-en-diccionarios"><a class="anchor" href="#compresión-basada-en-diccionarios">Compresión basada en diccionarios</a></h2>
<p>La compresión sin pérdida permite reconstruir los datos originales a la perfección. A diferencia de la compresión con pérdida, habitual en imágenes y audio, el resultado descomprimido no difiere del original ni en un solo bit. Cuando la integridad es esencial, como ocurre con el código fuente o los artefactos de compilación, hay que utilizar compresión sin pérdida.</p>
<p>Su idea central consiste en <strong>aprovechar la redundancia estadística presente en los datos</strong>. Si sustituimos patrones repetidos por representaciones más cortas, reducimos el tamaño total.</p>
<p>Entre estas técnicas, los métodos <strong>basados en diccionarios (Dictionary-Based)</strong> forman una familia muy extendida. Aquí, el diccionario es el mecanismo que permite volver a señalar datos ya vistos. Que ese mecanismo sean los propios datos ya recorridos o una lista construida aparte es lo que separa a LZ77 de LZ78. <strong>LZ77</strong>, propuesto por Jacob Ziv y Abraham Lempel en un <a href="https://doi.org/10.1109/TIT.1977.1055714" target="_blank" rel="noopener noreferrer">artículo</a> de 1977 en IEEE Transactions on Information Theory, y <strong>LZ78</strong>, del <a href="https://doi.org/10.1109/TIT.1978.1055934" target="_blank" rel="noopener noreferrer">artículo que ambos publicaron al año siguiente</a>, son los antepasados de esta familia. “LZ” toma una letra de cada apellido. Los algoritmos posteriores basados en diccionarios, como DEFLATE, LZMA, LZ4 y Zstd, descienden de ellos.</p>
<p>Pensemos en un ejemplo sencillo. Si la palabra “Linux” se repite cien veces, a partir de la segunda aparición se escribe, en lugar del texto original, una referencia corta que apunta a “ese fragmento visto antes”. Si la referencia es más corta que el original, el tamaño total se reduce.</p>
<p>Entonces, ¿en qué se diferencian exactamente LZ77 y LZ78?</p>
<h2 id="lz77-el-método-de-la-ventana-deslizante"><a class="anchor" href="#lz77-el-método-de-la-ventana-deslizante">LZ77: el método de la ventana deslizante</a></h2>
<p>LZ77 <strong>no crea un diccionario explícito independiente</strong>. Usa una región del propio flujo de entrada como diccionario. Esa región se llama <strong>ventana deslizante</strong> porque se desplaza hacia delante tanto como se ha procesado.</p>
<p>La ventana se divide en dos zonas.</p>
<ul>
<li><strong>Búfer de búsqueda (Search Buffer)</strong>: datos ya procesados. Cumple el papel de diccionario.</li>
<li><strong>Búfer de anticipación (Look-ahead Buffer)</strong>: datos aún no procesados que se comprimirán a continuación.</li>
</ul>
<p>El algoritmo busca si el comienzo del búfer de anticipación ya apareció en alguna parte del búfer de búsqueda. Si encuentra el mismo patrón, codifica la coincidencia como una tupla <strong>(distancia, longitud, carácter siguiente)</strong>. La distancia indica cuántos caracteres hay que retroceder para llegar al inicio de la coincidencia y la longitud, cuántos caracteres abarca. El artículo original registraba el primer valor como una posición dentro del búfer; las implementaciones actuales registran la misma información como una distancia.</p>
<p>Por ejemplo, si comprimimos la cadena <code>"banana_banana"</code> con el codificador didáctico incluido más adelante en este artículo, salen cinco tokens LZ77: <code>(0,0,b)</code> <code>(0,0,a)</code> <code>(0,0,n)</code> <code>(2,3,_)</code> <code>(7,5,a)</code>. Los tres primeros son caracteres vistos por primera vez, así que solo llevan el carácter siguiente, sin coincidencia. El segundo <code>"banana"</code> se resuelve con un único token, el último, <code>(7,5,a)</code>. Significa “retrocede siete caracteres, copia cinco y añade <code>a</code>”. No copia los seis caracteres porque este codificador siempre deja el último carácter de la entrada en la posición del carácter siguiente.</p>
<p>En <code>(2,3,_)</code>, la longitud 3 es mayor que la distancia 2. Con <code>ban</code> ya escrito, el decodificador empieza a copiar desde la <code>a</code> situada dos caracteres atrás y, al copiar el tercer carácter, vuelve a leer la <code>a</code> que acaba de escribir. Así sale <code>ana</code> y después se añade <code>_</code>. Seguido carácter a carácter, queda así.</p>
<table>
<thead>
<tr>
<th>Paso</th>
<th>Carácter leído</th>
<th>Salida</th>
</tr>
</thead>
<tbody>
<tr>
<td>Inicio</td>
<td>ninguno</td>
<td><code>ban</code></td>
</tr>
<tr>
<td>Copia 1</td>
<td>2.º carácter, <code>a</code></td>
<td><code>bana</code></td>
</tr>
<tr>
<td>Copia 2</td>
<td>3.º carácter, <code>n</code></td>
<td><code>banan</code></td>
</tr>
<tr>
<td>Copia 3</td>
<td>4.º carácter, <code>a</code> (escrito en la copia 1)</td>
<td><code>banana</code></td>
</tr>
<tr>
<td>Carácter siguiente</td>
<td><code>_</code> del 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> especifica el mismo comportamiento: si los dos últimos bytes son X e Y, <code>&#x3C;length = 5, distance = 2></code> añade X,Y,X,Y,X. Esto es posible porque el diccionario son los datos que se acaban de restaurar.</p>
<p>En este método <strong>el diccionario no se guarda ni se transmite por separado.</strong> El decodificador reconstruye por sí mismo el búfer de búsqueda mientras descomprime, de modo que el diccionario queda implícito en los propios datos. Como las referencias apuntan a datos anteriores, la descompresión avanza, en principio, en orden desde el comienzo. Sin embargo, las referencias solo alcanzan hasta donde llega la ventana. RFC 1951 limita las referencias de DEFLATE a un máximo de 32K bytes hacia atrás. El <code>Z_FULL_FLUSH</code> de zlib reinicia el estado de compresión para que la descompresión pueda reanudarse desde ese punto si los datos comprimidos anteriores se dañaron o si se necesita acceso aleatorio. <a href="https://github.com/madler/zlib/blob/v1.3.1/zlib.h" target="_blank" rel="noopener noreferrer">zlib.h</a> advierte que usarlo con demasiada frecuencia puede degradar seriamente la compresión.</p>
<p>El tamaño de la ventana mantiene una relación directa de compromiso con la tasa de compresión. Una ventana mayor puede referirse a patrones más lejanos y comprime mejor, pero usa más memoria y, como hay más candidatos que revisar, la búsqueda de coincidencias suele volverse más lenta.</p>
<h2 id="lz78-un-diccionario-explícito"><a class="anchor" href="#lz78-un-diccionario-explícito">LZ78: un diccionario explícito</a></h2>
<p>A diferencia de LZ77, LZ78 <strong>construye un diccionario explícito</strong> durante la compresión. No utiliza una ventana deslizante. Guarda los patrones observados como entradas indexadas y sustituye las repeticiones posteriores por sus índices.</p>
<p>LZ78 produce etiquetas con la forma <strong>(índice del diccionario, carácter siguiente)</strong>. El codificador busca la entrada más larga que coincida, emite su índice junto al carácter que rompe la coincidencia y añade <strong>“la entrada coincidente más el nuevo carácter”</strong> como otra entrada. El diccionario crece gradualmente durante el proceso.</p>
<p>Si comprimimos el mismo <code>"banana_banana"</code> con LZ78, salen ocho tokens y el diccionario acumula siete entradas. Los tokens son <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>, y el diccionario es <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>. El segundo <code>"banana"</code> se divide en tres tokens que producen <code>ba</code>, <code>na</code> y <code>na</code>. Es la parte que LZ77 resolvió con un solo token. Una entrada del diccionario de LZ78 crece solo un carácter cada vez, así que solo aparece una entrada larga después de ver la misma palabra varias veces. El último token, <code>(7,)</code>, es el caso en que la entrada termina y no hay carácter siguiente. Este codificador no empaqueta los tokens en bits, por lo que el número de tokens no sirve para comparar tasas de compresión.</p>
<p>Ambas secuencias de tokens se pueden reproducir con el código siguiente. Es el resultado de guardarlo en un archivo y ejecutar <code>node lz.mjs</code> el 2026-10-08 en macOS 26.6.2 con Node v24.16.0.</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="dónde-se-separan-los-dos"><a class="anchor" href="#dónde-se-separan-los-dos">Dónde se separan los dos</a></h2>
<p>Los dos decodificadores del código reciben solo tokens. Eso significa que <strong>LZ78 tampoco transmite su diccionario</strong>. El decodificador lee los tokens y añade entradas en el mismo orden que el codificador, así que se reconstruye el mismo diccionario. No transmitir el diccionario es algo que comparten ambos algoritmos; donde se separan es en <strong>cómo olvidan el contenido antiguo</strong>. La ventana de LZ77 avanza y olvida los datos viejos por sí sola. El diccionario de LZ78 sigue creciendo si se deja como está. El artículo original lo vaciaba por completo cada vez que terminaba un bloque de longitud fija, y las implementaciones reales limitan su tamaño y, cuando se llena, lo congelan, lo vacían o reutilizan algunas entradas.</p>
<p>La variante más conocida de LZ78 es <strong>LZW</strong> (Lempel-Ziv-Welch). Terry Welch publicó esta mejora en 1984, y se utilizó en el formato GIF y en la utilidad Unix <code>compress</code>, cuya extensión es <code>.Z</code>. Ambas implementaciones pusieron un límite al diccionario. La <a href="https://www.w3.org/Graphics/GIF/spec-gif89a.txt" target="_blank" rel="noopener noreferrer">especificación GIF89a</a> limita los códigos a 12 bits (valor máximo 4095) y define aparte un Clear code que devuelve el diccionario a su estado inicial. Según la <a href="https://github.com/vapier/ncompress/blob/v5.0/compress.1" target="_blank" rel="noopener noreferrer">página de manual de ncompress</a>, <code>compress</code> vigila la tasa de compresión una vez que la longitud de código alcanza el límite de <code>-b</code> (16 bits por defecto) y, si la tasa baja, descarta el diccionario y lo reconstruye desde cero.</p>
<p>Cada artículo demostró optimalidad asintótica dentro de su propio modelo. El artículo de 1977 mostró que la tasa de compresión de LZ77 “uniformly approaches the lower bounds” (se acerca uniformemente a las cotas inferiores) alcanzables por códigos diseñados conociendo la fuente de antemano, y el artículo de 1978 mostró, para secuencias individuales, que el incremental parsing de LZ78 es asintóticamente óptimo. Como los modelos son distintos, estos resultados por sí solos no permiten comparar ambos.</p>
<h2 id="descendientes-de-lz77"><a class="anchor" href="#descendientes-de-lz77">Descendientes de LZ77</a></h2>
<p>¿De cuál de los dos lados vienen, entonces, los algoritmos modernos? La mayoría de los algoritmos de compresión dominantes hoy son <strong>descendientes de LZ77</strong>.</p>
<p><strong>LZSS</strong>, que surgió del artículo de Storer y Szymanski de 1982, es una variante de LZ77. Cuando una coincidencia es tan corta que el puntero resultaría más largo que el original, emite un literal (el carácter original) en lugar del puntero.</p>
<p><strong>DEFLATE</strong> lo diseñó Phil Katz para PKZIP 2, y su especificación se recogió en 1996 como RFC 1951. RFC 1951 describe DEFLATE como la combinación de LZ77 y la <strong>codificación Huffman</strong>, que asigna secuencias de bits más cortas a los símbolos frecuentes. Su salida es una secuencia que mezcla literales y pares (longitud, distancia), y une literales y longitudes de coincidencia en un solo alfabeto (0 a 285) que se distingue con un único código Huffman. El método de compresión que la mayoría de los programas ZIP usan por defecto, así como GZIP y PNG, usan este DEFLATE. Por tanto, la mayoría de los archivos <code>.zip</code>, <code>.gz</code> y <code>.png</code> que manejamos a diario son descendientes directos de LZ77.</p>
<p>No hay una sola razón que explique por qué el lado de LZ77 se volvió dominante, pero una que las propias especificaciones destacan son las patentes. RFC 1951 señala que muchas variantes de LZ77 están patentadas y afirma que el formato DEFLATE puede implementarse fácilmente de una manera no cubierta por patentes. La <a href="https://www.w3.org/TR/png-3/" target="_blank" rel="noopener noreferrer">especificación PNG</a> presenta PNG desde su comienzo como un reemplazo de GIF libre de patentes. GIF es el formato que usa el LZW visto antes.</p>
<p>Algoritmos posteriores como <strong>LZMA</strong> (7-Zip y XZ), <strong>LZ4</strong> y <strong>Zstd</strong> también partieron de la idea de la ventana deslizante de LZ77. LZMA y Zstd desarrollaron a la vez la búsqueda de coincidencias y la codificación entrópica, mientras que LZ4 prescindió por completo de la codificación entrópica y optó por la velocidad con un formato orientado a bytes. La familia LZ78 sigue presente, en forma de LZW, dentro de formatos como GIF.</p>
<h2 id="conclusión"><a class="anchor" href="#conclusión">Conclusión</a></h2>
<p>LZ77 y LZ78 partieron de la misma idea, sustituir patrones repetidos por referencias breves. Ninguno transmite su diccionario y en ambos lo reconstruye el decodificador, pero se separaron en si una referencia apunta a una posición de los datos ya recorridos o al número de una entrada de un diccionario construido aparte, y también olvidan lo antiguo de forma distinta. Los compresores dominantes que van de DEFLATE a LZMA, LZ4 y Zstd descienden de LZ77, mientras que el lado de LZ78 sigue vivo como LZW en formatos como GIF.</p>
<p>Cómo se traduce este linaje en la elección de un formato real, comparando la velocidad y la tasa de compresión de ZIP, GZIP, ZSTD y XZ y decidiendo qué usar para artefactos de compilación, lo trato en <a href="/240706" target="_blank" rel="noopener noreferrer">Cómo funcionan los algoritmos de compresión</a>.</p>
<p>La próxima vez que descomprimas un archivo <code>.zip</code> o <code>.gz</code>, ojalá recuerdes que dentro se están intercambiando instrucciones como “retrocede tantos caracteres y copia tantos”.</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>curiosidades</category>
            <category>software</category>
        </item>
    </channel>
</rss>