<?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: Lei Wang</title>
    <description>The latest articles on DEV Community by Lei Wang (@otiswanglei).</description>
    <link>https://dev.to/otiswanglei</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%2F4135726%2F99e7556d-56e8-448f-b643-ca2c9fe14ee9.png</url>
      <title>DEV Community: Lei Wang</title>
      <link>https://dev.to/otiswanglei</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/otiswanglei"/>
    <language>en</language>
    <item>
      <title>Three Design Decisions I Reversed While Building a React Hook</title>
      <dc:creator>Lei Wang</dc:creator>
      <pubDate>Thu, 05 Nov 2026 15:13:27 +0000</pubDate>
      <link>https://dev.to/otiswanglei/three-design-decisions-i-reversed-while-building-a-react-hook-510i</link>
      <guid>https://dev.to/otiswanglei/three-design-decisions-i-reversed-while-building-a-react-hook-510i</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://dev.to/otiswanglei/why-i-built-standard-search-paramsreact-around-standard-schema-39lc"&gt;my previous post&lt;/a&gt; I introduced &lt;code&gt;@standard-search-params/react&lt;/code&gt;, a React hook that validates URL search params against any Standard Schema library. This post is about three decisions I changed my mind on while building v0.2.0.&lt;/p&gt;

&lt;p&gt;It isn't a story about a clean design arriving fully formed. It's a record of where my first idea was wrong and why I changed it, in case it's useful to anyone else building a small library.&lt;/p&gt;

&lt;p&gt;A note on process: I used AI (Claude and Claude Code) for design discussion and code review. Everything below is something I could confirm in the implementation and the tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: how the hook behaves
&lt;/h2&gt;

&lt;p&gt;The hook reads the URL search params &lt;strong&gt;once, on mount&lt;/strong&gt;, and validates each one against its own schema.&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="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validatedSearchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refresh&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStandardSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;v0.2.0 added a &lt;code&gt;listenToPopstate&lt;/code&gt; option and a &lt;code&gt;refresh()&lt;/code&gt; function for re-reading the URL after it changes. Building those two things is what led to the three reversals below.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "Wrap your schema in useMemo" fixed nothing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Context
&lt;/h3&gt;

&lt;p&gt;Sometimes the set of schema keys changes at runtime, for example when only admin users should have an extra param validated. Because the hook only reads the URL on mount, keys added later are &lt;strong&gt;silently ignored&lt;/strong&gt;. I decided to add a development warning so the problem would be noticed early.&lt;/p&gt;

&lt;h3&gt;
  
  
  My first idea had two holes
&lt;/h3&gt;

&lt;p&gt;My first idea was to warn whenever the schema's &lt;em&gt;reference&lt;/em&gt; changed, and to tell people in the docs to "memoize your schema with &lt;code&gt;useMemo&lt;/code&gt;".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hole 1: it would fire constantly.&lt;/strong&gt; The README says it's fine to write the schema inline in the component. An inline schema gets a new reference on every render, so a reference check would warn on the very pattern I recommend. A warning that fires every time is a warning nobody reads.&lt;/p&gt;

&lt;p&gt;So I compare the &lt;strong&gt;set of keys&lt;/strong&gt; instead of the reference:&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;schemaKeys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&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;prevSchemaKeysRef&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;schemaKeys&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isDev&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;prevSchemaKeysRef&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;schemaKeys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;prevSchemaKeysRef&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;schemaKeys&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;schemaKeys&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now an inline schema (same keys, new reference) stays quiet, and only a real change in the key set warns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hole 2: &lt;code&gt;useMemo&lt;/code&gt; doesn't fix anything.&lt;/strong&gt; This was the more important one. The hook's initial read runs in an effect on mount, once. Stabilizing the schema's reference with &lt;code&gt;useMemo&lt;/code&gt; never triggers a re-parse. &lt;code&gt;useMemo&lt;/code&gt; only guarantees that the reference stays the same. It has no effect like "re-read when the contents change".&lt;/p&gt;

&lt;p&gt;So "use &lt;code&gt;useMemo&lt;/code&gt;" was advice that looked like a fix and fixed nothing.&lt;/p&gt;

&lt;p&gt;The correct fix is to &lt;strong&gt;remount&lt;/strong&gt; the component:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchResults&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isAdmin&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&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;The warning message says so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;useStandardSearchParams: schema keys changed after mount (was [page], now [page,q]),
but this hook only reads the URL once, on mount, so the new keys won't be read.
If the set of keys genuinely needs to change at runtime, remount the component
(e.g. with a `key` prop) — memoizing the schema object does not cause a re-parse.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Two implementation mistakes too
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A Rules of Hooks violation.&lt;/strong&gt; My first version called the hooks inside a condition: &lt;code&gt;if (isDev) { useRef(...); useEffect(...) }&lt;/code&gt;. Even if the value is fixed at build time, writing it that way fails &lt;code&gt;react-hooks/rules-of-hooks&lt;/code&gt;. The hooks are called unconditionally, and the &lt;code&gt;isDev&lt;/code&gt; check lives inside the effect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking that &lt;code&gt;process&lt;/code&gt; exists.&lt;/strong&gt; A bare &lt;code&gt;process.env.NODE_ENV&lt;/code&gt; throws a &lt;code&gt;ReferenceError&lt;/code&gt; in environments without a bundler. Check &lt;code&gt;typeof process !== 'undefined'&lt;/code&gt; first.
&lt;/li&gt;
&lt;/ul&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;isDev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What I took from it
&lt;/h3&gt;

&lt;p&gt;Before writing a fix into a warning or a doc, check that it actually works in the implementation. Here that was a matter of looking at an effect's dependency array.&lt;/p&gt;

&lt;p&gt;This warning also has a limit. If the keys stay the same but a validator's contents change (say &lt;code&gt;max(5)&lt;/code&gt; becomes &lt;code&gt;max(10)&lt;/code&gt;), it can't tell. Standard Schema's internals differ between libraries, so comparing contents in a general way isn't realistic, and I decided to accept that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. I didn't turn popstate on by default
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;popstate&lt;/code&gt; event fires when the user presses the browser's back or forward button. My first plan was to listen for it by default: it's a native API, the listener is cheap, and the side effects seemed small.&lt;/p&gt;

&lt;p&gt;What changed my mind was consistency with the library's own design.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The core of this hook is predictable behavior: the URL is read &lt;strong&gt;once, on mount&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The README, the docstrings, and the warning above are all written on that premise.&lt;/li&gt;
&lt;li&gt;Turning &lt;code&gt;popstate&lt;/code&gt; on by default means "values change by themselves when you press back" would apply to existing users too. The API doesn't break, but &lt;strong&gt;the behavior changes without telling anyone&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So it's off by default, and you opt in explicitly:&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;validatedSearchParams&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStandardSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;listenToPopstate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more detail. SPA router navigation such as &lt;code&gt;router.push()&lt;/code&gt; or &lt;code&gt;navigate()&lt;/code&gt; only pushes a history entry, and it doesn't fire &lt;code&gt;popstate&lt;/code&gt;. So &lt;code&gt;listenToPopstate&lt;/code&gt; doesn't cover that case either. For that, call &lt;code&gt;refresh()&lt;/code&gt; whenever the router's location changes:&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;// Next.js example&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;validatedSearchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refresh&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStandardSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&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;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePathname&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;searchParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSearchParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. &lt;code&gt;refresh()&lt;/code&gt; deduplication created a "silently does nothing" case
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Context
&lt;/h3&gt;

&lt;p&gt;On a back navigation, both the &lt;code&gt;popstate&lt;/code&gt; listener and a &lt;code&gt;refresh()&lt;/code&gt; call from the router's effect can fire for the same navigation. Parsing twice is wasteful, so I made &lt;code&gt;refresh()&lt;/code&gt; return early when &lt;code&gt;location.search&lt;/code&gt; is the same as the last one it read.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem
&lt;/h3&gt;

&lt;p&gt;That deduplication had a side effect I missed. If &lt;strong&gt;the URL is the same but the schema's rules have changed&lt;/strong&gt; and you want to re-validate, &lt;code&gt;refresh()&lt;/code&gt; is silently ignored.&lt;/p&gt;

&lt;p&gt;The name &lt;code&gt;refresh()&lt;/code&gt; suggests "read again when I call it." Silently doing nothing because of an internal optimization doesn't match what the name promises.&lt;/p&gt;

&lt;h3&gt;
  
  
  The options
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;(a) Document that it only has an effect when the URL changes&lt;/li&gt;
&lt;li&gt;(b) Add a flag to force it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose (b). A mismatch between a name and its behavior should be fixed in the design, not papered over in the docs. The deduplication exists to serve the automatic &lt;code&gt;popstate&lt;/code&gt; listener. An explicit call to &lt;code&gt;refresh()&lt;/code&gt; is entitled to an escape hatch.&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="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;refreshOptions&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;lastSearchRef&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;currentSearch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// re-validate against the current schema even if the URL is the same&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default stays "no force." If you call &lt;code&gt;refresh()&lt;/code&gt; on every router navigation, many navigations leave the query string unchanged, and re-validating each time would be wasteful. Only the unusual case, where you want to re-read the same URL, requires you to opt in.&lt;/p&gt;

&lt;p&gt;The tests pin this behavior down: with the same URL and a changed schema, no &lt;code&gt;force&lt;/code&gt; is ignored and &lt;code&gt;force: true&lt;/code&gt; takes effect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two smaller fixes in the implementation
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;useMemo&lt;/code&gt; on the return value.&lt;/strong&gt; In my design draft, the return value had turned back into a plain object literal. That returns a new reference on every render and breaks the existing test that checks referential stability. I went back to wrapping it in &lt;code&gt;useMemo&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="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="na"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parse&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;span class="nx"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merging state into one &lt;code&gt;useState&lt;/code&gt;.&lt;/strong&gt; Keeping &lt;code&gt;isSearchParamsReady&lt;/code&gt; in a separate &lt;code&gt;useState&lt;/code&gt; means two &lt;code&gt;setState&lt;/code&gt; calls per &lt;code&gt;parse()&lt;/code&gt;. React 18 batches them automatically, but this library's &lt;code&gt;peerDependencies&lt;/code&gt; allow &lt;code&gt;react &amp;gt;=16.8&lt;/code&gt;. In React 17 and earlier, a &lt;code&gt;setState&lt;/code&gt; called from a native event like &lt;code&gt;popstate&lt;/code&gt; isn't batched, so it can cause two re-renders. Putting the values in a single state object makes it one &lt;code&gt;setState&lt;/code&gt; call.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;These three changes share a way of thinking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't silently change behavior, and don't silently do nothing.&lt;/strong&gt; That's why &lt;code&gt;popstate&lt;/code&gt; isn't on by default and why &lt;code&gt;refresh()&lt;/code&gt; doesn't quietly ignore a call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that the fix works before writing it down.&lt;/strong&gt; The &lt;code&gt;useMemo&lt;/code&gt; advice is the example.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix a mismatch between a name and its behavior in the design, not in the docs.&lt;/strong&gt; &lt;code&gt;refresh({ force: true })&lt;/code&gt; is the example.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When adding features, I want to keep checking that I'm not breaking what existing users already expect from the library.&lt;/p&gt;

&lt;p&gt;Next time, I plan to write about how I worked on this with AI, including how design discussion and code review were split up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/@standard-search-params/react" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@standard-search-params/react&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/otiswanglei/standard-search-params" rel="noopener noreferrer"&gt;https://github.com/otiswanglei/standard-search-params&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Japanese version (日本語版) is available on Zenn: &lt;a href="https://zenn.dev/wdp/articles/ece2138453fb9a" rel="noopener noreferrer"&gt;https://zenn.dev/wdp/articles/ece2138453fb9a&lt;/a&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Why I Built @standard-search-params/react Around Standard Schema</title>
      <dc:creator>Lei Wang</dc:creator>
      <pubDate>Mon, 21 Sep 2026 12:13:43 +0000</pubDate>
      <link>https://dev.to/otiswanglei/why-i-built-standard-search-paramsreact-around-standard-schema-39lc</link>
      <guid>https://dev.to/otiswanglei/why-i-built-standard-search-paramsreact-around-standard-schema-39lc</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Have you ever written code like this to handle URL search params in React?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&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;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;q&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&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's simple, but writing this over and over as your app grows gets tedious. And the fallback behavior for an invalid value (like &lt;code&gt;page=abc&lt;/code&gt;) tends to differ depending on who wrote the code.&lt;/p&gt;

&lt;p&gt;A number of libraries already tackle this by combining a validation library like Zod with URL params. &lt;code&gt;@standard-search-params/react&lt;/code&gt; takes the same approach, but it's built around a spec called &lt;strong&gt;Standard Schema&lt;/strong&gt; instead of any single validation library.&lt;br&gt;
&lt;/p&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; @standard-search-params/react
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What is Standard Schema?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/standard-schema/standard-schema" rel="noopener noreferrer"&gt;Standard Schema&lt;/a&gt; is a spec that lets different validation libraries — Zod, Valibot, ArkType, and others — expose a common interface. It's intentionally minimal: a validator just needs a &lt;code&gt;~standard&lt;/code&gt; property with a shared &lt;code&gt;validate()&lt;/code&gt; method.&lt;/p&gt;

&lt;p&gt;What makes this useful is that &lt;strong&gt;library authors no longer need to build separate integrations for Zod, Valibot, and so on&lt;/strong&gt;. Build one tool against Standard Schema, and it works the same way regardless of which validator your consumers happen to use.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@standard-search-params/react&lt;/code&gt; is built so it works with any Standard Schema-compliant validator.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useStandardSearchParams&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@standard-search-params/react&lt;/span&gt;&lt;span class="dl"&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;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;SearchResults&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validatedSearchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isSearchParamsReady&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nf"&gt;useStandardSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&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;isSearchParamsReady&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;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;validatedSearchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you'd rather use Valibot, swap &lt;code&gt;z.coerce.number()&lt;/code&gt; for &lt;code&gt;v.pipe(v.string(), v.transform(Number))&lt;/code&gt; — the hook itself doesn't change at all. You can even mix Zod and Valibot in the same schema object.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;valibot&lt;/span&gt;&lt;span class="dl"&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;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;h2&gt;
  
  
  Why per-field schemas instead of one big object schema?
&lt;/h2&gt;

&lt;p&gt;Most validation hooks for URL params validate the whole query string against a single object schema. That approach has a weakness: &lt;strong&gt;one invalid field can invalidate everything.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;?page=2&amp;amp;q=hello&amp;amp;sort=bad
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;sort&lt;/code&gt; holds an unexpected value here and you're validating the whole object as one schema, a perfectly valid &lt;code&gt;page&lt;/code&gt; and &lt;code&gt;q&lt;/code&gt; can get thrown out along with it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@standard-search-params/react&lt;/code&gt; avoids this by taking a &lt;strong&gt;plain object mapping each param name to its own schema&lt;/strong&gt;, validated independently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;          &lt;span class="c1"&gt;// { page: '2', q: 'hello', sort: 'bad' }  (raw strings)&lt;/span&gt;
&lt;span class="nx"&gt;validatedSearchParams&lt;/span&gt; &lt;span class="c1"&gt;// { page: 2, q: 'hello' }                  (parsed, sort dropped)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;sort&lt;/code&gt; gets excluded from &lt;code&gt;validatedSearchParams&lt;/code&gt;; &lt;code&gt;page&lt;/code&gt; and &lt;code&gt;q&lt;/code&gt; come through fine. One broken param not taking down the rest matters more than it sounds once you're dealing with stale bookmarks or hand-edited URLs in a real product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Client-side only, on purpose
&lt;/h2&gt;

&lt;p&gt;This hook reads &lt;code&gt;window.location.search&lt;/code&gt;, so it &lt;strong&gt;never runs during server rendering&lt;/strong&gt;. That's an intentional design choice, not an oversight.&lt;/p&gt;

&lt;p&gt;It's safe to render inside an SSR framework (no crash, no hydration mismatch): &lt;code&gt;isSearchParamsReady&lt;/code&gt; stays &lt;code&gt;false&lt;/code&gt; through the server render and the first client render, then flips to &lt;code&gt;true&lt;/code&gt; once the client has read and validated the URL. There's necessarily a brief "not loaded yet" moment before that, though — that part can't be avoided.&lt;/p&gt;

&lt;p&gt;If you need validated search params as part of the initial server-rendered output (e.g. a Next.js Server Component, which receives &lt;code&gt;searchParams&lt;/code&gt; as a plain prop), skip the hook and validate that object directly with your schema instead. This hook is meant for &lt;strong&gt;client-rendered apps (SPAs) or client components&lt;/strong&gt; that read params after mount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling browser back/forward
&lt;/h2&gt;

&lt;p&gt;v0.2.0 added two ways to react to the URL changing after mount.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;browser back/forward buttons&lt;/strong&gt;, pass &lt;code&gt;listenToPopstate&lt;/code&gt;:&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;validatedSearchParams&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStandardSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;listenToPopstate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's &lt;code&gt;false&lt;/code&gt; by default. That's intentional — the hook's core design is "read the URL once, on mount," and I didn't want to change that behavior silently for existing users. Automatic re-validation is something you opt into, not something that happens by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SPA route pushes&lt;/strong&gt; (&lt;code&gt;router.push()&lt;/code&gt;, &lt;code&gt;navigate()&lt;/code&gt;) don't fire &lt;code&gt;popstate&lt;/code&gt;, so there's a separate &lt;code&gt;refresh()&lt;/code&gt; function for that case:&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;// Next.js&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;validatedSearchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refresh&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStandardSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&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;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePathname&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;searchParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSearchParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A small safeguard for development
&lt;/h2&gt;

&lt;p&gt;This hook reads the URL &lt;strong&gt;once, on mount.&lt;/strong&gt; That means an inline schema object (a fresh reference every render) is totally fine — only the keys present on the very first render are ever read. But it also means there's a footgun if &lt;strong&gt;the set of schema keys itself&lt;/strong&gt; needs to change at runtime — for example, validating extra params only for admin users.&lt;/p&gt;

&lt;p&gt;To catch this early, there's a dev-only &lt;code&gt;console.warn&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;useStandardSearchParams: schema keys changed after mount
(was [page], now [page,q]), but this hook only reads the URL once,
on mount, so the new keys won't be read. If the set of keys genuinely
needs to change at runtime, remount the component (e.g. with a `key`
prop) — memoizing the schema object does not cause a re-parse.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As the message says, the fix is a &lt;code&gt;key&lt;/code&gt; prop to force a remount — not &lt;code&gt;useMemo&lt;/code&gt;. Memoizing the schema stabilizes its reference, but it does nothing to trigger a re-parse, so the warning spells out the actual fix rather than pointing toward an intuitive-but-wrong one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@standard-search-params/react&lt;/code&gt; is a small library built around a few ideas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Standard Schema support, so it works with Zod, Valibot, ArkType, or whatever you're already using&lt;/li&gt;
&lt;li&gt;Per-field validation, so one broken param doesn't take the rest down with it&lt;/li&gt;
&lt;li&gt;Client-side only, clearly documented rather than papered over&lt;/li&gt;
&lt;li&gt;Browser-back and SPA-router support, both opt-in on top of a safe default
I'd rather keep the feature set small and the behavior predictable than pile on options.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback and issues are always welcome.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/@standard-search-params/react" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/@standard-search-params/react&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/otiswanglei/standard-search-params" rel="noopener noreferrer"&gt;https://github.com/otiswanglei/standard-search-params&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Japanese version (日本語版) is available on Zenn: &lt;a href="https://zenn.dev/wdp/articles/72729dd75e506b" rel="noopener noreferrer"&gt;https://zenn.dev/wdp/articles/72729dd75e506b&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Follow-up: &lt;a href="https://dev.to/otiswanglei/three-design-decisions-i-reversed-while-building-a-react-hook-510i"&gt;Three Design Decisions I Reversed While Building a React Hook&lt;/a&gt;&lt;/p&gt;

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