⚖️

Next.js の クエリパラメータ読み取りに秩序を

2022/07/11に公開

クエリ読み取りのつらみ

  • クエリパラメータを検索UIの状態と同期したい時
  • クエリパラメータをnumberにパースしたい時

特に Next.js でこのようなページを作成する際、クエリパラメータを読み取る処理は面倒くさいし、注意するべきことが多くミスを防ぐ必要が出てきている気がします。

そこで、ステート管理ライブラリ群における Selector の考え方を参考にして、カスタムフックを作成してみました。

参考にした記事:https://zenn.dev/warabi/articles/2521222d57a71f


❗Hydration 時の不一致エラー

1つ目のつらみは、Hydration時の不一致エラーです。

  • router.isReady そのままでは条件付きレンダリングに利用できない
  • Hydration未完了 / クエリ未指定 / クエリの形式エラー の区別がしたい場合...
    • 一本の関数で書く訳にいかず、useState / 更新処理 のパターンが必要になる
    • isReady を利用せず、クエリが undefined かどうかで分岐」で済ますことが出来ない。

❗useState を直に使うことの問題

2つ目のつらみは、useStateを使わないといけない(ことがある)点です。

  • useState() をそのままで使うと、単方向のデータフローが崩れる危険性がある。
    • 値をsetする関数が露出することとなるため。
    • クエリパラメータが変わるたびに変更する処理も並べて書くことになり、カオス
  • そもそも、クエリパラメータが変わるたびに変更する処理を忘れやすい
    • useState(なんらかの式) と書くと、レンダーするたびに「なんらかの式」が評価されて値が更新される」という勘違いが割とある。
    • そう書いた場合、URL 直打ち or ブラウザバック によってパラメータが変えるまでバグに気づかない可能性あり。
  • 単純にクエリパラメータのオブジェクトの扱いがめんどくさいので別関数に分けておきたい。
    • string | string[] | undefined なので...

カスタムフックにしてみた

そこで、こんな型のカスタムフックにしてみました。

declare const useQueryBasedState: <State>(
  selectorFn: (query: ParsedUrlQuery) => State | undefined
) => State | undefined

例えば、検索UIの状態に反映する場合、

検索UI.tsx
  // この型でクエリを管理する
  type State = { keyword: string };

  const state = useQueryBasedState<State>(
    (query) => ({
      // keywordの最初の値を取得するおまじない
      keyword: [query.keyword ?? []].flat()[0] ?? "",
    }),
    {
      onUpdate: (newState) => {
        // state が変更されるたびに reset処理を呼び出す
        reset({ keyword: newState.keyword });
      },
    }
  );
  );

(stateが変更されるたびに...の処理は https://beta.reactjs.org/learn/you-might-not-need-an-effect を参考にしています。)

あるいは、クエリパラメータの一つを数値に変換する場合はこんな風に

数値へのパース.tsx
  type ParseResult =
    | { invalid: false; value: number }
    | {
        invalid: true;
        raw: string | string[] | undefined;
      };

  // これは純粋関数なので外に出せる
  const parseIntParameter = (
    raw: string | string[] | undefined
  ): ParseResult => {
    // 複数またはゼロ個になることはない。明示的にアサート。
    if (typeof raw !== "string") return { invalid: true, raw };

    // 半角数字の連続のみであればパースする
    if (/^[0-9]+$/.test(raw))
      return { invalid: false, value: parseInt(raw, 10) };

    // それに当てはまらない場合はinvalid
    return { invalid: true, raw: raw };
  };

  const state = useQueryBasedState<ParseResult>((query) =>
    parseIntParameter(query.postId)
  );

コンポーネントで利用したいデータを取得するのに、router.query -> 目的のデータ の型の純粋な関数を渡すだけで、細かなボイラープレートを書かなくて良いようなカスタムフックを作ってみました。

useQueryBasedState.ts
const useQueryBasedState = <State>(
  selectorFn: (query: ParsedUrlQuery) => State,
  { onUpdate }: { onUpdate?: Dispatch<State> } = {}
): State | undefined => {
  const router = useRouter();

  // クエリをオブジェクトにエンコードしたものを保持する
  const [state, _setState] = useState<State | undefined>(undefined);
  const setState = useCallback((newValue: State) => {
    _setState(newValue)
    onUpdate?.(newValue)
  }, [onUpdate])

  // クエリが一切ない場合は、最初のrenderの時点で isReady が true になっているので、
  // その時には一回だけ useEffect で遅延させてstateを更新する
  const countRef = useRef(0);
  useEffect(() => {
    if (!router.isReady) return;
    if (countRef.current > 0) return;
    setState(selectorFn(router.query));
    countRef.current += 1;
  }, [onUpdate, router.isReady, router.query, selectorFn, setState]);

  // hydrationが終了してクエリが読み込まれた時にstateを更新し、onUpdateを発火する
  const [prevIsReady, setPrevIsReady] = useState(router.isReady);
  console.log({ prevIsReady, isReady: router.isReady });
  if (!prevIsReady && router.isReady) {
    setState(selectorFn(router.query));
    setPrevIsReady(router.isReady);
  }

  // クエリの変化を検知してstateを更新し、onUpdateを発火する
  const [prevAsPath, setPrevAsPath] = useState(router.asPath);
  if (prevAsPath !== router.asPath) {
    setState(selectorFn(router.query));
    setPrevAsPath(router.asPath);
  }

  return state;
};

export default useQueryBasedState;

hydration error 対策済み

router.isReady をそのまま使って条件付きレンダリングすると、Hydration エラーが発生します。(未Hydration時の表示内容とHydrationされた結果の表示内容がズレるため)

// ❌ ダメな例()
 return router.isReady && ( 
   // ここにハイドレーション済みの時に表示される要素を書く
 )

ですが、今回のカスタムフックでは以下のような記述をしていることによって、 「stateがundefinedであるか否か」によって条件付きレンダリングに使えます。

useQueryBasedState.ts
  // クエリをオブジェクトにエンコードしたものを保持する
  const [state, setState] = useState<State | undefined>(undefined);
  
  // hydrationが終了してクエリが読み込まれた時にstateを更新し、onUpdateを発火する
  const [prevIsReady, setPrevIsReady] = useState(router.isReady);
  if (!prevIsReady && router.isReady) {
    setState(selectorFn(router.query));
    setPrevIsReady(router.isReady);
  }
条件付きレンダリングの例
  const state = useQueryBasedState<State>(selectotFn);
  // 中略
  return (state !== undefined) && (
    // ハイドレーション済みの時に表示される要素
  )

最後に

やっぱり、こういうややこしい記述はカスタムフックで共通化するに限りますね。
そして Small is beautiful.

Discussion