Introduction
In my previous post I introduced @standard-search-params/react, 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.
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.
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.
Background: how the hook behaves
The hook reads the URL search params once, on mount, and validates each one against its own schema.
const schema = {
page: z.coerce.number().int().min(1),
q: z.string().min(1),
};
const { validatedSearchParams, refresh } = useStandardSearchParams(schema);
v0.2.0 added a listenToPopstate option and a refresh() function for re-reading the URL after it changes. Building those two things is what led to the three reversals below.
1. "Wrap your schema in useMemo" fixed nothing
Context
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 silently ignored. I decided to add a development warning so the problem would be noticed early.
My first idea had two holes
My first idea was to warn whenever the schema's reference changed, and to tell people in the docs to "memoize your schema with useMemo".
Hole 1: it would fire constantly. 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.
So I compare the set of keys instead of the reference:
const schemaKeys = Object.keys(schema).sort().join(',');
const prevSchemaKeysRef = useRef(schemaKeys);
useEffect(() => {
if (isDev && prevSchemaKeysRef.current !== schemaKeys) {
console.warn(/* ... */);
prevSchemaKeysRef.current = schemaKeys;
}
}, [schemaKeys]);
Now an inline schema (same keys, new reference) stays quiet, and only a real change in the key set warns.
Hole 2: useMemo doesn't fix anything. This was the more important one. The hook's initial read runs in an effect on mount, once. Stabilizing the schema's reference with useMemo never triggers a re-parse. useMemo only guarantees that the reference stays the same. It has no effect like "re-read when the contents change".
So "use useMemo" was advice that looked like a fix and fixed nothing.
The correct fix is to remount the component:
<SearchResults key={isAdmin ? 'admin' : 'user'} />
The warning message says so:
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.
Two implementation mistakes too
-
A Rules of Hooks violation. My first version called the hooks inside a condition:
if (isDev) { useRef(...); useEffect(...) }. Even if the value is fixed at build time, writing it that way failsreact-hooks/rules-of-hooks. The hooks are called unconditionally, and theisDevcheck lives inside the effect. -
Checking that
processexists. A bareprocess.env.NODE_ENVthrows aReferenceErrorin environments without a bundler. Checktypeof process !== 'undefined'first.
const isDev =
typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
What I took from it
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.
This warning also has a limit. If the keys stay the same but a validator's contents change (say max(5) becomes max(10)), 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.
2. I didn't turn popstate on by default
The popstate 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.
What changed my mind was consistency with the library's own design.
- The core of this hook is predictable behavior: the URL is read once, on mount.
- The README, the docstrings, and the warning above are all written on that premise.
- Turning
popstateon by default means "values change by themselves when you press back" would apply to existing users too. The API doesn't break, but the behavior changes without telling anyone.
So it's off by default, and you opt in explicitly:
const { validatedSearchParams } = useStandardSearchParams(schema, {
listenToPopstate: true,
});
One more detail. SPA router navigation such as router.push() or navigate() only pushes a history entry, and it doesn't fire popstate. So listenToPopstate doesn't cover that case either. For that, call refresh() whenever the router's location changes:
// Next.js example
const { validatedSearchParams, refresh } = useStandardSearchParams(schema);
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
refresh();
}, [pathname, searchParams, refresh]);
3. refresh() deduplication created a "silently does nothing" case
Context
On a back navigation, both the popstate listener and a refresh() call from the router's effect can fire for the same navigation. Parsing twice is wasteful, so I made refresh() return early when location.search is the same as the last one it read.
The problem
That deduplication had a side effect I missed. If the URL is the same but the schema's rules have changed and you want to re-validate, refresh() is silently ignored.
The name refresh() suggests "read again when I call it." Silently doing nothing because of an internal optimization doesn't match what the name promises.
The options
- (a) Document that it only has an effect when the URL changes
- (b) Add a flag to force it
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 popstate listener. An explicit call to refresh() is entitled to an escape hatch.
if (!refreshOptions?.force && lastSearchRef.current === currentSearch) {
return;
}
refresh({ force: true }); // re-validate against the current schema even if the URL is the same
The default stays "no force." If you call refresh() 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.
The tests pin this behavior down: with the same URL and a changed schema, no force is ignored and force: true takes effect.
Two smaller fixes in the implementation
-
The
useMemoon the return value. 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 inuseMemo.
return useMemo(() => ({ ...state, refresh: parse }), [state, parse]);
-
Merging state into one
useState. KeepingisSearchParamsReadyin a separateuseStatemeans twosetStatecalls perparse(). React 18 batches them automatically, but this library'speerDependenciesallowreact >=16.8. In React 17 and earlier, asetStatecalled from a native event likepopstateisn't batched, so it can cause two re-renders. Putting the values in a single state object makes it onesetStatecall.
Wrapping up
These three changes share a way of thinking:
-
Don't silently change behavior, and don't silently do nothing. That's why
popstateisn't on by default and whyrefresh()doesn't quietly ignore a call. -
Check that the fix works before writing it down. The
useMemoadvice is the example. -
Fix a mismatch between a name and its behavior in the design, not in the docs.
refresh({ force: true })is the example.
When adding features, I want to keep checking that I'm not breaking what existing users already expect from the library.
Next time, I plan to write about how I worked on this with AI, including how design discussion and code review were split up.
- npm: https://www.npmjs.com/package/@standard-search-params/react
- GitHub: https://github.com/otiswanglei/standard-search-params
Japanese version (日本語版) is available on Zenn: https://zenn.dev/wdp/articles/ece2138453fb9a
Top comments (1)
Writing up the reversals is more useful than the final API, since that's where the real constraints show. Reading the URL once on mount and offering refresh() explicitly is a nice honest default. Did you consider subscribing to Next.js or React Router navigation too, since popstate alone misses pushState changes?