0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

URLパラメータ用Reactフックの設計で、一度決めたことを見直した3つの話

0
Posted at

TL;DR

  • URLクエリパラメータ検証のReactフック @standard-search-params/react を作る中で、一度決めた設計を見直した3つの判断の記録です
  • 「useMemo で安定させてください」という案内は、実装上は何も解決していませんでした
  • popstate 対応はデフォルトOFFに、refresh() には強制フラグを足しました

こんな人向けの記事です

  • 小さなライブラリやフックを作っていて、設計判断の振り返りを読みたい
  • 警告メッセージやドキュメントに書く「対処法」が、本当に効くのか確かめる方法を知りたい
  • React の popstate や router.push() とURLの同期まわりで、迷ったことがある

はじめに

前回の記事で、URLクエリパラメータを Standard Schema で検証する React フック @standard-search-params/react を紹介しました。この記事では、v0.2.0 を作る過程で「一度決めたことを見直した」3つの判断を書きます。

きれいに完成した設計の話ではなく、最初の案のどこが間違っていたか、なぜ変えたかの記録です。小さなライブラリを作っている人の参考になればうれしいです。

なお、設計の相談とレビューには AI(Claude と Claude Code)を使いました。記事に載せているのは、実装とテストで確認できた内容だけです。

前提: このフックの挙動

このフックは、URL のクエリパラメータをマウント時に1回だけ読み、キーごとに用意したスキーマで検証します。

const schema = {
  page: z.coerce.number().int().min(1),
  q: z.string().min(1),
};

const { validatedSearchParams, refresh } = useStandardSearchParams(schema);

v0.2.0 では、URL が変わったときに読み直すための listenToPopstate オプションと refresh() を追加しました。この2つを作る中で、見直した判断が3つあります。

1. 「useMemo で安定させてください」は何も解決しなかった

背景

スキーマのキーが実行時に変わるケースがあります。たとえば、管理者ユーザーだけ追加のパラメータを検証したい場合です。このフックはマウント時にしか URL を読まないので、あとから増えたキーは静かに無視されます。開発中に気づけるよう、警告を出すことにしました。

最初の案には穴が2つあった

最初の案は、「スキーマの参照が変わったら警告する」というものでした。あわせて、ドキュメントには「スキーマを useMemo で安定させてください」と書くつもりでした。

穴1: 誤検知で毎回鳴る。 README では、スキーマをコンポーネント内にインラインで書いてもよいとしています。インラインで書くと、スキーマの参照は毎レンダリング変わります。参照比較で警告すると、推奨している書き方でも毎回鳴ってしまいます。警告が毎回鳴れば、誰も見なくなります。

そこで、参照ではなくキーの集合を比較するようにしました。

const schemaKeys = Object.keys(schema).sort().join(',');
const prevSchemaKeysRef = useRef(schemaKeys);

useEffect(() => {
  if (isDev && prevSchemaKeysRef.current !== schemaKeys) {
    console.warn(/* ... */);
    prevSchemaKeysRef.current = schemaKeys;
  }
}, [schemaKeys]);

これなら、インラインスキーマ(キーは同じで参照だけ変わる)では鳴らず、キー構成が変わったときだけ鳴ります。

穴2: useMemo は何も直さない。 こちらのほうが重要でした。このフックの初回読み込みは、マウント時の effect で1回だけ走ります。スキーマの参照を useMemo で安定させても、再パースは起きません。useMemo が保証するのは「参照が変わらないこと」だけで、「内容が変わったら読み直す」効果は一切ありません。

つまり「useMemo を使ってください」という案内は、直ったように見えて何も直していない処方箋でした。

正しい対処は、コンポーネントをリマウントすることです。

<SearchResults key={isAdmin ? 'admin' : 'user'} />

警告メッセージにも、この対処を書きました。

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.

実装でも2点つまずいた

  • Rules of Hooks 違反。 最初のコードは、if (isDev) { useRef(...); useEffect(...) } のように、フックを条件分岐の中で呼んでいました。ビルド時に固定される値でも、ソースコードの書き方としては react-hooks/rules-of-hooks に引っかかります。フックは無条件に呼び、isDev の判定は effect の中で行います。
  • process の存在確認。 process.env.NODE_ENV だけだと、バンドラーを通さない環境では ReferenceError になります。typeof process !== 'undefined' を先に確認します。
const isDev =
  typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';

学んだこと

警告やドキュメントに書く対処法は、実装上、本当にそれで直るかを確かめてから書く。この件は、実装の依存配列を見れば分かることでした。

なお、この警告にも限界があります。キーは同じで、バリデータの中身(max(5) が max(10) に変わる、など)だけが変わるケースは検知できません。Standard Schema の内部構造はライブラリごとに違うので、汎用的に中身を比較することは現実的ではないと判断し、割り切りました。

2. popstate 対応はデフォルトで ON にしなかった

ブラウザの戻る/進むボタンでは popstate イベントが発火します。最初は「popstate のリスナーはネイティブ API だけで完結するし、副作用も小さいので、デフォルトで ON にしよう」と考えていました。

見直したきっかけは、このライブラリの設計思想との一貫性でした。

  • このフックの核は「URL はマウント時に1回だけ読む」という、予測できる挙動です。
  • README も、docstring も、先ほどの警告も、すべてこの前提で書かれています。
  • popstate をデフォルトで ON にすると、「戻るボタンを押したら勝手に値が変わる」挙動が、既存の利用者にも自動で適用されます。API は壊れませんが、挙動が黙って変わります。

そこで、デフォルトは OFF にして、使いたい人が明示的にオプトインする形にしました。

const { validatedSearchParams } = useStandardSearchParams(schema, {
  listenToPopstate: true,
});

ひとつ補足があります。router.push() や navigate() のような SPA ルーターの遷移は、pushState で履歴を積むだけで、popstate を発火しません。そのため listenToPopstate を ON にしても、こちらは拾えません。この場合は、refresh() をルーターの location の変化に合わせて呼びます。

// Next.js の例
const { validatedSearchParams, refresh } = useStandardSearchParams(schema);
const pathname = usePathname();
const searchParams = useSearchParams();

useEffect(() => {
  refresh();
}, [pathname, searchParams, refresh]);

3. refresh() の重複排除が「黙って何もしない」を生んだ

背景

ブラウザの戻る操作では、popstate のリスナーと、ルーター側の effect から呼んだ refresh() の両方が、同じ遷移で発火することがあります。そのまま2回パースするのは無駄なので、直前に読んだ location.search と同じなら、何もせず戻るようにしました。

問題

この重複排除には、見落としていた副作用がありました。URL は同じで、スキーマの中身(バリデーションルール)だけを変えて refresh() したいケースでも、黙って無視されてしまいます。

refresh() という名前から素直に期待するのは「呼んだら読み直す」ことです。内部の最適化の都合で、黙って何もしないことがあるのは、名前から期待される挙動と食い違っています。

選択肢

  • (a) 「URL が変わったときだけ効く」とドキュメントに書いて割り切る
  • (b) 強制フラグを用意する

(b) にしました。名前と挙動のずれは、ドキュメントで埋めるのではなく、設計で直すべきだと考えたからです。重複排除は、popstate の自動リスナーのための最適化です。利用者が明示的に呼ぶ refresh() には、逃げ道があってよいはずです。

if (!refreshOptions?.force && lastSearchRef.current === currentSearch) {
  return;
}
refresh({ force: true }); // URL が同じでも、現在のスキーマで再検証する

デフォルトは force なしにしています。ルーターの遷移のたびに refresh() を呼ぶ使い方では、クエリ文字列が変わらない遷移も多く、そのたびに再検証が走るのは無駄だからです。「URL が同じでも読み直したい」という特殊なケースだけ、利用者が明示的に選びます。

「URL が同じでスキーマだけ変えた場合、force なしなら無視され、force: true なら反映される」という挙動は、テストでも固定しています。

実装で見直した小さな点が2つ

  • 戻り値の useMemo。 設計の下書きでは、戻り値がただのオブジェクトリテラルに戻っていました。これだと毎レンダリング新しい参照を返すので、参照の安定性を確認する既存のテストが落ちます。useMemo で包んだ形に戻しました。

    return useMemo(() => ({ ...state, refresh: parse }), [state, parse]);
    
  • state を1つにまとめる。 isSearchParamsReady を別の useState にすると、parse() のたびに setState が2回走ります。React 18 では自動バッチングされますが、このライブラリの peerDependencies は react >=16.8 です。React 17 以前では、popstate のようなネイティブイベントから呼ばれる setState はバッチされず、再レンダリングが2回起きる余地があります。値を1つの state にまとめて、setState を1回にしました。

まとめ

3つの見直しに共通していたのは、次の考え方です。

  • 黙って挙動を変えない、黙って何もしない。 popstate をデフォルトで ON にしない、refresh() を無言で無視しない、という判断です。
  • 処方箋は、効くかどうかを確かめてから書く。 useMemo の案内がその例です。
  • 名前から期待される挙動とのずれは、ドキュメントではなく設計で直す。 refresh({ force: true }) がその例です。

機能を足すときほど、既存の利用者が持っている「このライブラリはこう動くはず」という期待を、壊していないかを確認したいと思います。

次回は、この開発を AI と進めた過程(設計の相談とレビューの役割分担など)を書く予定です。


※ Zenn にも同じ内容を投稿しています: https://zenn.dev/wdp/articles/ece2138453fb9a

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?