<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Maaz Bin Tariq</title>
    <description>The latest articles on DEV Community by Maaz Bin Tariq (@mzsleepyzzz).</description>
    <link>https://dev.to/mzsleepyzzz</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3996324%2F17bb64b5-da4e-4924-93e1-e05ba9e44eef.png</url>
      <title>DEV Community: Maaz Bin Tariq</title>
      <link>https://dev.to/mzsleepyzzz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mzsleepyzzz"/>
    <language>en</language>
    <item>
      <title>Smoothing streamed LLM text in React: it looks like a typewriter loop. It isn't.</title>
      <dc:creator>Maaz Bin Tariq</dc:creator>
      <pubDate>Fri, 26 Jun 2026 03:38:21 +0000</pubDate>
      <link>https://dev.to/mzsleepyzzz/smoothing-streamed-llm-text-in-react-it-looks-like-a-typewriter-loop-it-isnt-eb3</link>
      <guid>https://dev.to/mzsleepyzzz/smoothing-streamed-llm-text-in-react-it-looks-like-a-typewriter-loop-it-isnt-eb3</guid>
      <description>&lt;h2&gt;
  
  
  Smoothing streamed LLM text in React: it looks like a typewriter loop. It isn't.
&lt;/h2&gt;

&lt;p&gt;When a model streams a reply, the text doesn't arrive one character at a time. It arrives in clumps — a couple of words, a pause, then a whole sentence at once. Render each clump as it lands and the output stutters: text jumps, stalls, jumps again. The chat apps you actually like to use hide this. The words glide in at a steady, readable pace, and that smoothness is faked on top of bursty data.&lt;/p&gt;

&lt;p&gt;Faking it looks like a five-line typewriter loop. It isn't — at least not if you want it correct across languages, honest about latency, and free of a flash that only shows up when a user hits "regenerate."&lt;/p&gt;

&lt;p&gt;The snippets below are distilled from &lt;a href="https://github.com/Maaz046/use-smooth-text" rel="noopener noreferrer"&gt;&lt;code&gt;use-smooth-text&lt;/code&gt;&lt;/a&gt;, a small zero-dependency hook I put on npm. The production code is a little more careful than what's here, but these are the same ideas.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiu2z4qleqgq1dtlhgnsl.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiu2z4qleqgq1dtlhgnsl.gif" alt="The same streamed text rendered two ways: raw on the left (jumping in bursts) and through use-smooth-text on the right (revealed smoothly)." width="719" height="358"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Before the traps: why the browser, not the server
&lt;/h2&gt;

&lt;p&gt;The popular way to get this is the Vercel AI SDK's &lt;code&gt;smoothStream&lt;/code&gt;. It works well, with one catch that's easy to miss: it's a &lt;strong&gt;server-side transform&lt;/strong&gt;, and that server has to be JavaScript. It re-chunks the model's output as it passes through your Node process before it reaches the browser.&lt;/p&gt;

&lt;p&gt;Many LLM backends aren't Node. They're Python, Go, Rust, or something behind a gateway. If that's you, &lt;code&gt;smoothStream&lt;/code&gt; isn't available — the code path doesn't exist on your side.&lt;/p&gt;

&lt;p&gt;But smoothing is a &lt;em&gt;rendering&lt;/em&gt; concern, not a transport one. By the time text reaches the browser it's just a string, and a string is a string whether it came from FastAPI over SSE or a Node server over a WebSocket. So do the smoothing in the browser, on whatever text has arrived so far. That one decision makes it backend-agnostic — and it's the whole reason the hook is controlled by a plain string you already have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSmoothText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You keep appending chunks to &lt;code&gt;full&lt;/code&gt; however you received them. The hook hands back a smoothly-growing slice of it. It never knows your backend exists.&lt;/p&gt;

&lt;p&gt;Now the three traps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: "one character at a time" is a lie in most of the world
&lt;/h2&gt;

&lt;p&gt;The obvious reveal is &lt;code&gt;full.slice(0, n)&lt;/code&gt;, incrementing &lt;code&gt;n&lt;/code&gt;. That indexes by UTF-16 code unit, and plenty of characters are more than one code unit. A 👍 is two. A 👨‍👩‍👧 family emoji is many, glued together with zero-width joiners. Slice through the middle and you render half a surrogate pair — a broken-glyph "tofu" box — for one frame, every time one passes.&lt;/p&gt;

&lt;p&gt;It's worse for whole writing systems. "Reveal a word at a time" by splitting on spaces does nothing useful for Chinese or Japanese, and stepping by code unit mangles combining marks in Arabic and Hebrew.&lt;/p&gt;

&lt;p&gt;The browser already knows where the real boundaries are. &lt;code&gt;Intl.Segmenter&lt;/code&gt; gives you graphemes (or words, locale-aware) so you only ever cut where a human would:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Segmenter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;granularity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;grapheme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// boundaries: the character offsets you're allowed to cut at&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;boundaries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;seg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;boundaries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;segment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reveal up to the largest boundary that's ≤ where you're "supposed" to be, and a half-emoji never reaches the screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: a fixed speed is either too slow or a lie
&lt;/h2&gt;

&lt;p&gt;The typewriter loop reveals one unit every &lt;code&gt;N&lt;/code&gt; milliseconds. Pick a speed and you've picked a problem.&lt;/p&gt;

&lt;p&gt;Too slow, and you fall behind a fast model and &lt;em&gt;stay&lt;/em&gt; behind — the user finishes reading, the buffer's still draining, the text crawls long after the answer arrived. Too fast, and you're back to rendering the bursts you were trying to smooth out.&lt;/p&gt;

&lt;p&gt;The fix is to make the speed depend on how far behind you are. Reveal at a base rate normally, but when a big chunk lands and the backlog grows, speed up enough to clear it within a short window:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;arrivedChars&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;revealedChars&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;charsPerSec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;CATCH_UP_SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it glides during the quiet stretches and accelerates through bursts, landing roughly when the stream does instead of lagging forever.&lt;/p&gt;

&lt;p&gt;This is also where the tradeoff becomes visible to users. Smoothing trades a little immediacy for a steadier read. If you under-set the base rate, that trade shows — the smoothed text finishes noticeably after the raw stream, and it looks &lt;em&gt;slower&lt;/em&gt; rather than nicer. Set the base rate near your model's actual token throughput and the delay becomes imperceptible; it evens out the cadence rather than adding to it. The knob is there because the right value depends on your model, not because there's a free lunch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: the flash I almost shipped
&lt;/h2&gt;

&lt;p&gt;This one didn't show up in the demo, the tests, or local clicking around. I caught it by accident while testing the &lt;strong&gt;regenerate&lt;/strong&gt; flow: the message gets replaced with a completely different reply, and for a single frame the new text flashed at the wrong length before it corrected itself.&lt;/p&gt;

&lt;p&gt;The displayed text is &lt;code&gt;full.slice(0, revealedChars)&lt;/code&gt;. When &lt;code&gt;full&lt;/code&gt; is swapped for new, unrelated text, &lt;code&gt;revealedChars&lt;/code&gt; is still the count from the &lt;em&gt;old&lt;/em&gt; message. So for one frame you render &lt;code&gt;newText.slice(0, oldCount)&lt;/code&gt; — a chunk of the new reply, at the wrong length — before anything corrects it.&lt;/p&gt;

&lt;p&gt;My first instinct was to reset &lt;code&gt;revealedChars&lt;/code&gt; to 0 in an effect when the text changes. That's too late: effects run &lt;em&gt;after&lt;/em&gt; the browser paints, so the wrong frame is already on screen. The actual fix is to do the reset &lt;em&gt;during&lt;/em&gt; render, using React's "adjust state during render" pattern, so React re-renders with the corrected value before it ever paints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// during render, not in an effect&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prevText&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setPrevText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;// always track the latest text&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isReset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prevText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;full&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setRevealedChars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;// only on a real reset — corrected before paint, no flash&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isReset&lt;/code&gt; here is just "the new text isn't an extension of the old one" — a normal streamed append (&lt;code&gt;"hel"&lt;/code&gt; → &lt;code&gt;"hello"&lt;/code&gt;) isn't a reset; a regenerate is. The lesson that stuck: &lt;em&gt;when&lt;/em&gt; you fix derived state matters as much as whether you fix it. An effect was the intuitive place and the wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;It smooths the visual reveal, not the network. It can't make a slow server fast — but doing it in the browser is exactly what makes it work everywhere.&lt;/li&gt;
&lt;li&gt;Feed it human-readable text, not tool-call or JSON payloads. You don't want to type out a function-call argument one character at a time.&lt;/li&gt;
&lt;li&gt;It's append-only. It expects the string to grow; a non-prefix change is treated as a new message (see trap 3), not an edit of what's already shown.&lt;/li&gt;
&lt;li&gt;It doesn't parse markdown. For streaming markdown, pair it with a renderer that completes partial syntax; revealing by word instead of character also cuts down on mid-token flicker.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;use-smooth-text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a side-by-side demo in the repo that's easier to judge than a GIF — and the package is about 1 kB min+gzip with zero runtime dependencies.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/use-smooth-text" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/use-smooth-text&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Maaz046/use-smooth-text" rel="noopener noreferrer"&gt;https://github.com/Maaz046/use-smooth-text&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing started as "this is just a &lt;code&gt;setInterval&lt;/code&gt;." It mostly isn't — the interesting parts are the boundaries you're allowed to cut on, the speed you reveal at, and the single frame between a render and an effect.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Form drafts that survive a closed tab — and the 5 bugs everyone ships first</title>
      <dc:creator>Maaz Bin Tariq</dc:creator>
      <pubDate>Mon, 22 Jun 2026 16:19:36 +0000</pubDate>
      <link>https://dev.to/mzsleepyzzz/form-drafts-that-survive-a-closed-tab-and-the-5-bugs-everyone-ships-first-5aic</link>
      <guid>https://dev.to/mzsleepyzzz/form-drafts-that-survive-a-closed-tab-and-the-5-bugs-everyone-ships-first-5aic</guid>
      <description>&lt;h2&gt;
  
  
  Saving React form drafts to localStorage: five failure modes, three from one mistake
&lt;/h2&gt;

&lt;p&gt;A user fills out a long form, the tab closes, and the work is gone. The fix looks like two &lt;code&gt;useEffects&lt;/code&gt;. It isn't.&lt;/p&gt;

&lt;p&gt;A naive "persist to &lt;code&gt;localStorage&lt;/code&gt;" hook breaks in five distinct ways, and most of them never show up in the demo, the code review, or local testing. Three of the five aren't separate bugs at all — they're the same mistake showing up in three places.&lt;/p&gt;

&lt;p&gt;The snippets below are distilled from &lt;a href="https://github.com/Maaz046/use-form-draft" rel="noopener noreferrer"&gt;&lt;code&gt;use-form-draft&lt;/code&gt;&lt;/a&gt;, a small hook I wrote and put on npm. It's the worked example; the simplified versions here are easier to read than the production code, but they're the same ideas.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The five failure modes:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The empty initial state overwrites a saved draft on first paint.&lt;/li&gt;
&lt;li&gt;React 18 StrictMode double-mounts and writes anyway.&lt;/li&gt;
&lt;li&gt;React Hook Form re-renders write to storage on every render.&lt;/li&gt;
&lt;li&gt;Accessing &lt;code&gt;localStorage&lt;/code&gt; throws and takes the form down with it.&lt;/li&gt;
&lt;li&gt;A draft from an old schema corrupts the new form, or crash-loops it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first three share a single cause. The last two each need their own guard.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks right, and it works when you test it by hand. It breaks in the situations you don't hit locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes 1–3 share one cause
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The empty state overwrites the draft on first paint.&lt;/strong&gt; Effects run in the order they're declared, so the write effect goes first: it serializes the empty initial form straight over yesterday's saved draft. The restore effect runs immediately after and reads back the empty value that was just written. The draft is gone, and nothing brings it back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. StrictMode writes anyway.&lt;/strong&gt; In development, React 18 StrictMode mounts, unmounts, and remounts to surface side-effect bugs. The usual patch for failure 1 (a &lt;code&gt;mountedRef&lt;/code&gt; that skips the first run) fails here: the ref survives the remount and already reads &lt;code&gt;true&lt;/code&gt; on the second mount. The guard lets the write through and clobbers the draft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. React Hook Form thrashes storage.&lt;/strong&gt; &lt;code&gt;form.watch()&lt;/code&gt; returns a new object reference on every render. A naive &lt;code&gt;[values]&lt;/code&gt; dependency then fires a synchronous &lt;code&gt;setItem&lt;/code&gt; on every keystroke &lt;em&gt;and&lt;/em&gt; on every unrelated re-render up the tree. It works, but it writes far more than it should.&lt;/p&gt;

&lt;p&gt;All three write because a &lt;em&gt;render&lt;/em&gt; happened, not because the &lt;em&gt;data&lt;/em&gt; changed. So compare the data. Keep the last thing you serialized, and write only when the new serialization differs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Seed with the initial serialized state, so the first run sees "no change"&lt;/span&gt;
&lt;span class="c1"&gt;// and writes nothing. This also covers StrictMode's double mount.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lastWritten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;lastWritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// data didn't change → no write&lt;/span&gt;
  &lt;span class="nx"&gt;lastWritten&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one comparison closes all three holes. The empty initial state matches the seed, so first paint writes nothing. StrictMode's second mount matches it too. And React Hook Form's fresh-but-identical object serializes to the same string, so none of those re-renders write. Only a real edit produces a different string.&lt;/p&gt;

&lt;p&gt;Two caveats, since this leans on &lt;code&gt;JSON.stringify&lt;/code&gt; as a content hash. It assumes the state is JSON-serializable with stable key order. For plain, string-keyed form objects that usually holds, though engines order integer-like keys numerically regardless of insertion, so avoid numeric field names. The bigger gap is values that don't round-trip: &lt;code&gt;Map&lt;/code&gt; and &lt;code&gt;Set&lt;/code&gt; both serialize to &lt;code&gt;{}&lt;/code&gt;, &lt;code&gt;undefined&lt;/code&gt; fields drop out, and &lt;code&gt;NaN&lt;/code&gt;/&lt;code&gt;Infinity&lt;/code&gt; become &lt;code&gt;null&lt;/code&gt;. Any of those can make two different states hash the same and skip a write, so hash a normalized shape if your form holds them.&lt;/p&gt;

&lt;p&gt;The full version in the package adds a debounce, strips excluded fields (passwords, card numbers) before hashing, and wraps the &lt;code&gt;JSON.stringify&lt;/code&gt; itself, but the load-bearing idea is that one comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 4: accessing &lt;code&gt;localStorage&lt;/code&gt; can throw
&lt;/h2&gt;

&lt;p&gt;This one never reproduces on your machine, because your machine is fine.&lt;/p&gt;

&lt;p&gt;Touching &lt;code&gt;localStorage&lt;/code&gt; can throw, and not only when you write to it. A full quota or old Safari's private mode throws on the &lt;code&gt;setItem&lt;/code&gt;. A sandboxed iframe, or a "block site data" policy, throws on the &lt;em&gt;property access itself&lt;/em&gt; (&lt;code&gt;window.localStorage&lt;/code&gt;) before any method runs, and &lt;code&gt;typeof&lt;/code&gt; doesn't suppress that. (Modern Safari, Firefox, and Chrome private/incognito modes are fine: they give you a working, smaller-quota store and stopped throwing on normal writes years ago.)&lt;/p&gt;

&lt;p&gt;Because all of this sits on the render path, an exception doesn't stay contained — it can take down the whole form the helper was meant to protect. So both the access and every method have to degrade to a quiet no-op:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getStore&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Storage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the access can throw, so guard it too&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Storage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// quota full, disabled by policy, old Safari private mode.&lt;/span&gt;
    &lt;span class="c1"&gt;// a dropped draft is fine; crashing the form isn't.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeRemove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Storage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* ignore */&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same wrapping goes around &lt;code&gt;getItem&lt;/code&gt; and &lt;code&gt;JSON.stringify&lt;/code&gt; too (the latter throws on a &lt;code&gt;BigInt&lt;/code&gt; or a circular reference). Persistence here is best-effort: a failed write just means there's no saved draft, which the user can live with. It never gets to throw into the form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 5: a stale draft corrupts the new schema
&lt;/h2&gt;

&lt;p&gt;Forms change shape. You ship v2 where &lt;code&gt;title&lt;/code&gt; goes from a &lt;code&gt;string&lt;/code&gt; to &lt;code&gt;{ en, ar }&lt;/code&gt;, and a user still has a v1 draft in storage. You hydrate it, and last month's data pours into this month's inputs. Usually that just renders garbage — a blank field, a wrong value. Sometimes it throws, and if it throws unhandled, the broken draft stays in storage and crash-loops the form on every remount, so a refresh can't even rescue the user.&lt;/p&gt;

&lt;p&gt;Two cheap guards handle it. Stamp a schema version on every draft and discard mismatches on read, and delete any draft whose hydrate throws.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Draft&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;savedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;T&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Storage&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Draft&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Draft&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// shape changed → discard&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// corrupted JSON → discard&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// on mount:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;draft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;FormShape&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SCHEMA_VERSION&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;hydrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;safeRemove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// one poisoned record can't break every visit&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bump &lt;code&gt;SCHEMA_VERSION&lt;/code&gt; on any incompatible change and old drafts get dropped on read instead of breaking the form. The same &lt;code&gt;savedAt&lt;/code&gt; field also drives an expiry check and a "restored 3 minutes ago" label.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tests are the proof
&lt;/h2&gt;

&lt;p&gt;These are easy to fix once you know they exist. The catch is that all five are invisible until production, so they need tests that pin the behaviour rather than code that happens to work today. Each failure mode in the package is one named, runnable &lt;a href="https://github.com/Maaz046/use-form-draft/blob/main/src/useFormDraft.test.ts" rel="noopener noreferrer"&gt;regression test&lt;/a&gt;: no write on the StrictMode double mount, no write on an identical-content re-render, a no-op when storage throws (including the property-access throw), a discarded draft on version mismatch, a deleted draft when hydrate throws. There's an SSR test too, for the server-render path.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you might not need a library
&lt;/h2&gt;

&lt;p&gt;If you only use React Hook Form and want no recovery UI, a smaller RHF-specific package will weigh less. If you want autosave to a &lt;em&gt;server&lt;/em&gt;, this whole category is the wrong tool. And if your form state is plain and you just want the behaviour, the guard from the first section is most of the job — copy it.&lt;/p&gt;

&lt;p&gt;But if you want the rest of it handled — the storage-throw guards, schema versioning, a debounce, a recovery banner, and adapters for React Hook Form, Formik, and TanStack Form — that's what &lt;a href="https://www.npmjs.com/package/use-form-draft" rel="noopener noreferrer"&gt;&lt;code&gt;use-form-draft&lt;/code&gt;&lt;/a&gt; packages. Either way, the &lt;a href="https://github.com/Maaz046/use-form-draft/blob/main/src/useFormDraft.test.ts" rel="noopener noreferrer"&gt;test file&lt;/a&gt; is the fastest way to see each of these five bugs reproduced and fixed.&lt;/p&gt;

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