🛍️

一つのクエリキーに複数の情報を詰め込んだtype safeなhooksを作った

に公開

始めに

タブやページネーション情報など、クエリに情報を持たせることでリロードしても状態が残っていたり、ブラウザバックで前の状態に戻れるなどのメリットがあります。しかしクエリにたくさん情報を持たせるとどこでどういうケースで使われるものか分かりづらくなります。例えば特定のタブの中で使われるテーブルのページネーションをそれぞれクエリで管理すると該当のタブが非表示なのにページネーションの情報が残っているなどチグハグなことが起きることがあると思います。連動して使われるパラメータは一つのクエリキーに詰め込まれていた方がそういう矛盾がなくなると思います。
一つのクエリキーに複数の情報を詰め込む場合は、パースとクエリに保存するhooksを作っておくことで呼び出し側は気軽に扱えるようになると思います。

タブ値とページネーションをペアにしてクエリ値で管理するhooks
import { useSearchParams } from 'react-router';
import { useMemo, useCallback } from 'react';

type TabAndPagination = {
  tab: string
  page: number
}

const useTabAndPaginationQueryValue = (queryKey: string) => {
  const [searchParams, setSearchParams] = useSearchParams();
  const tabAndPagination: TabAndPagination | null = useMemo(() => {
    const queryStr = searchParams.get(queryKey);
    if (typeof queryStr !== 'string') {
      return null
    }
    const [tab, pageStr] = queryStr.split('_');
    return { tab, page: parseInt(pageStr) };
  }, [queryKey, searchParams]);

  const updateTabAndPagination = useCallback(
    (newTabAndPagination: TabAndPagination | null) => {
      setSearchParams((prevSearchParams) => {
        if (newTabAndPagination == null) {
          prevSearchParams.delete(queryKey);
          return prevSearchParams;
        }

        prevSearchParams.set(
          queryKey,
          [newTabAndPagination.tab, newTabAndPagination.page].join('_')
        );
        return prevSearchParams;
      });
    }, 
    [queryKey, setSearchParams]
  );

  return {
    tabAndPagination,
    updateTabAndPagination,
  };
}

ただこの書き方だとかなり限定されたケースでしか使用できず、ユースケースごとにhooksを作る必要が発生します。また上記のコードは簡易的なもので全てのタブに対してページネーションが設定されていますが、不要な場合は未設定にするように実装するとより複雑になります。
この問題は以前作ったサイドピークの表示で使うクエリ値にも当てはまります。以下の記事のサンプルを例にすると、{ type: 'setting' }{ type: 'sentence'; page: number }のどちらかにパースする必要があります。

https://zenn.dev/castingone_dev/articles/react_side_peak

こうした複雑になる実装に対して、より汎用的な共通コードを用意してどういう風にパース・クエリ文字列化するかを指定するだけで期待する値でtype safeに扱えるようにしたhooksを作りましたので、その実装内容についてまとめました。

一つのクエリキーに複数の情報を詰め込んだtype safeなhooksの実装

サイドピークを例に挙げると、パース後の結果を{ type: 'setting' }{ type: 'sentence'; page: number }のような形にできるようなインターフェースにしたいと思います。どのサイドピークを使うかはtypeで管理し、他のプロパティはオプショナルで追加する形になると良さそうです。このオプショナル要素は最終的にパース処理をする際に返す必要があるので、そこから判断できそうです。まとめると、以下のようなサイドピーク値の取り得るパターンリストを渡すようにします。

サイドピーク値の取り得るパターン配列
const parsePageNumber = (pageStr?: string | null) => {
  if (typeof pageStr !== 'string') {
    return undefined;
  }
  if (!/^[0-9]+$/.test(pageStr)) {
    return undefined;
  }
  return parseInt(pageStr);
};

// この配列を使って type SidePeakValue = { type: 'setting' } | { type: 'sentence'; page: number } を取得できるようにする
const SIDE_PEAK_PATTERNS = [
  { type: 'setting' },
  {
    type: 'sentence',
    // クエリ文字列からサイドピーク値にする
    parser: (queryStr) => {
      const page = parsePageNumber(queryStr);
      return page != null ? { page } : null;
    },
    // クエリに保存するための文字列化(typeは共通で処理するため、それ以外のパラメータのみ文字列化)
    stringify: (payload) => {
      return `${payload.page}`;
    }
  },
]

これをベースに汎用的なhooksを作っていきますが、型の算出がかなり難しいところなので、そちらを重点的に説明していきます。

typeのみとparser込みのパターンの型定義

まずは引数に渡すパターンの型を定義します。typeのみとparserとstringifyも渡す場合があるため、条件分岐をして以下のようになります。

パースされたクエリ値の取り得るパターンの型
type IsEmptyObject<T> = [keyof T] extends [never] ? true : false;

/** パースされたクエリ値の取り得るパターン */
export type ParsedQueryValuePattern<
  /** クエリの種別 */
  Type extends string,
  /** クエリデータ */
  Payload extends object = {}
> = IsEmptyObject<Payload> extends true
  ? {
      /** クエリの種別 */
      type: Type;
    }
  : {
      /** クエリの種別 */
      type: Type;
      /**
       * クエリ文字列からクエリ値にパースする(パースできない時はnullを返す)
       * @param queryStr - クエリ文字列
       */
      parser: (queryStr: string) => Payload | null;
      /**
       * クエリ文字列に保存するための文字列データを返す
       * @param payload - クエリ値
       */
      stringify: (payload: Payload) => string;
    };

例に挙げたパターン配列にこの型を使う場合は、それぞれのパターンに分けて定義すると良さそうです。

サイドピーク値の取り得るパターンに型を当てる
const SETTING_SIDE_PEAK_PATTERN: ParsedQueryValuePattern<'setting'> = {
  type: 'setting',
};

const SENTENCE_SIDE_PEAK_PATTERN: ParsedQueryValuePattern<
  'sentence',
  { page: number }
> = {
  type: 'sentence',
  parser: (queryStr) => {
    const page = parsePageNumber(queryStr);
    return page != null ? { page } : null;
  },
  stringify: (payload) => {
    return `${payload.page}`;
  },
};

パース後の型をパターンから算出する

続いてはパターンからパースされた値の型を算出します。パターンの型はparserがないものとあるものがあるので、それぞれのケースで型を算出します。

単一のParsedQueryPatternからパース後の値を取得する
/** { a: number } & { b: string }{ a: number; b: string } のように単純なオブジェクト型に整理する */
type Simplify<T> = { [K in keyof T]: T[K] };

/** ジェネリクスで使用する受け入れ可能な型 */
type AllowParsedQueryValuePattern =
  | ParsedQueryValuePattern<string>
  | ParsedQueryValuePattern<string, any>;

/** 単一のParsedQueryPatternからパース後の値を取得する */
type SingleParsedQueryValue<Pattern extends AllowParsedQueryValuePattern> =
  Pattern extends ParsedQueryValuePattern<string, any>
    ? Simplify<{ type: Pattern['type'] } & ReturnType<Pattern['parser']>>
    : Pattern extends ParsedQueryValuePattern<string>
    ? { type: Pattern['type'] }
    : never;

これでそれぞれのパターンに適切なサイドピークの値が算出されます。

SingleParsedQueryValueで取得される型の例
type SettingSidePeakValue = SingleParsedQueryValue<typeof SETTING_SIDE_PEAK_PATTERN>
// { type: 'setting' }

type SentenceSidePeakValue = SingleParsedQueryValue<typeof SENTENCE_SIDE_PEAK_PATTERN>
// { type: 'sentence'; page: number }

これを複数版にも対応できるように、再帰を使って一つずつチェックするようにします。

複数のParsedQueryPatternからパース後の値を取得する
/** 複数のParsedQueryPatternからパース後の値を取得する */
type MultipleParsedQueryValue<
  Patterns extends readonly AllowParsedQueryValuePattern[]
> = Patterns extends readonly [
  infer Head extends AllowParsedQueryValuePattern,
  ...infer Rest extends readonly AllowParsedQueryValuePattern[]
]
  ? SingleParsedQueryValue<Head> | MultipleParsedQueryValue<Rest>
  : never;

事前に作った型を使ってhooksを実装

パターンとパース後の型ができたので、それを使ってhooksを実装します。クエリ文字列の形式はサイドピークのtypeと追加データを文字列化したものを_で結合したもので実装しました。例で挙げたパターンを使うと例えば以下のような文字列を想定しています。

  • SETTING_SIDE_PEAK_PATTERN: setting
  • SENTENCE_SIDE_PEAK_PATTERN: sentence_1

この形式で動くようにparse、stringifyするコードを書くと以下のようになりました。

パースされたクエリ値を管理するhooks
/**
 * パースされたクエリ値を管理するhooks
 * @param queryKey - 参照するクエリキー
 * @param patterns - パースされたクエリ値の取り得るパターン
 */
export const useParsedQueryValue = <
  Patterns extends readonly AllowParsedQueryValuePattern[]
>(
  queryKey: string,
  patterns: Patterns
) => {
  const [searchParams, setSearchParams] = useSearchParams();

  const parsedQueryValue: MultipleParsedQueryValue<Patterns> | null =
    useMemo(() => {
      const queryStr = searchParams.get(queryKey);
      if (typeof queryStr !== 'string') {
        return null;
      }
      for (const pattern of patterns) {
        if (queryStr.startsWith(pattern.type)) {
          if ('parser' in pattern === false) {
            return { type: pattern.type };
          }
          // `${type}_` の部分を取り除いた部分をパースする
          const payload = pattern.parser(
            queryStr.slice(pattern.type.length + 1)
          );
          if (payload == null) {
            console.warn('クエリ値のパースに失敗しました:', queryStr);
            return null;
          }
          return { type: pattern.type, ...payload };
        }
      }
      // 最後までマッチしなかった場合
      console.warn(`クエリのパターンに合致しませんでした:`, queryStr);
      return null;
    }, [queryKey, patterns, searchParams]);

  const updateParsedQueryValue = useCallback(
    (value: MultipleParsedQueryValue<Patterns> | null) => {
      setSearchParams((prevSearchParams) => {
        if (value == null) {
          prevSearchParams.delete(queryKey);
          return prevSearchParams;
        }

        for (const pattern of patterns) {
          if (pattern.type === value.type) {
            if ('stringify' in pattern === false) {
              prevSearchParams.set(queryKey, pattern.type);
              return prevSearchParams;
            }

            const str = pattern.stringify(value);
            // typeと文字列した値を_で結合してクエリに保存する
            prevSearchParams.set(queryKey, `${pattern.type}_${str}`);
            return prevSearchParams;
          }
        }

        // 最後までマッチしなかった場合
        console.warn('クエリのパターンに合致しませんでした:', value);
        return prevSearchParams;
      });
    },
    [queryKey, patterns, setSearchParams]
  );

  return {
    /** パースされたクエリ値 */
    parsedQueryValue,
    /**
     * パースされたクエリ値を更新(クエリ文字列を更新する)
     * @param value - パースされたクエリ値
     */
    updateParsedQueryValue,
  };
};

サイドピークとして使う

あとはこのhooksにパターンを含めてサイドピーク用のhooksにします。折角なので名前もサイドピークに寄せた名前にリネームして返します。

サイドピーク値を管理するhooks
const SIDE_PEAK_PATTERNS = [
  SETTING_SIDE_PEAK_PATTERN,
  SENTENCE_SIDE_PEAK_PATTERN,
] as const;

/**
 * サイドピーク値を管理するhooks
 */
export const useSidePeakValue = () => {
  const {
    parsedQueryValue: sidePeakValue,
    updateParsedQueryValue: updateSidePeakValue,
  } = useParsedQueryValue('sidePeak', SIDE_PEAK_PATTERNS);

  return {
    /** サイドピーク値 */
    sidePeakValue,
    /**
     * サイドピーク値の更新
     * @param value - サイドピーク値
     */
    updateSidePeakValue,
  };
};

後はこのhooksを使って値を更新するだけでOKです。サイドピークの実装の際に使用したサンプルコードに対して差し替えると以下のように変わります。

useSidePeakValueを使うように変更
 const HomePage: FC = () => {
-  const [searchParams] = useSearchParams();
-
-  const sidePeakType = searchParams.get('sidePeak');
-  const paramPage = parsePageNumber(searchParams.get('page'));
+  const { sidePeakValue, updateSidePeakValue } = useSidePeakValue();
 
   return (
     <div>
       <h3>HOME画面</h3>
       <Stack alignItems="flex-start" spacing={1}>
         <Button
-          component={NavLink}
-          variant={sidePeakType === 'setting' ? 'contained' : 'outlined'}
-          to="?sidePeak=setting"
+          // クリックイベントで実装。リンクでやる場合は後述
+          variant={sidePeakValue?.type === 'setting' ? 'contained' : 'outlined'}
+          onClick={() => {
+            updateSidePeakValue({
+              type: 'setting',
+            });
+          }}
         >
           設定画面をサイドピークで開く
         </Button>
         <Box sx={{ pt: 2 }}>
           <Box>文章ページをサイドピークで開く</Box>
           <Stack direction="row" useFlexGap flexWrap="wrap" spacing={0.5}>
             {Array.from({ length: 5 }).map((_, i) => {
               const page = i + 1;
-              const isActive =
-                sidePeakType === 'sentence' && page === paramPage;
+              const isActive =
+                sidePeakValue?.type === 'sentence' &&
+                sidePeakValue.page === page;
               return (
                 <Button
                   key={i}
-                  component={NavLink}
                   variant={isActive ? 'contained' : 'outlined'}
-                  to={`/?sidePeak=sentence&page=${page}`}
+                  onClick={() => {
+                    updateSidePeakValue({
+                      type: 'sentence',
+                      page,
+                    });
+                  }}
                 >
                   ページ{page}
                 </Button>
              );
             })}
           </Stack>
         </Box>
       </Stack>
       <SidePeakEntry />
     </div>
   )
 }

その他

以上が最低限の実装ですが、より色々なパターンに対応できるようにするためにやったことを載せます。

サイドピークの値を更新ではなくURLに足せるクエリ文字列を生成する

元々のサイドピークのサンプルコードではクエリ文字列を設定してリンクとして動いていました。サイドピークはリンクというよりボタンのイメージが強いのでわざわざリンクにする必要もないと思いますが、他のケースだと必要になる可能性もあるので実装しました。基本的にはupdateParsedQueryValueでprevSearchParamsを更新する直前のデータをtoString()したらクエリ文字列は取得できるので、コードは以下のようになりました。searchParamsを使っているのは他のクエリ値が含まれている場合も考慮して最終的なクエリ文字列を返すためです。このhooksに関わっている文字列だけ返す方法もありますが、その後に結合作業が必要になるのは流石に面倒かなと思って一緒のクエリ文字列を返しています。今回はやりませんがもっと丁寧にやるなら既存のクエリ文字列を考慮するかをフラグで渡して選べると良いと思います。

パース後の値を含めてクエリ文字列化するメソッドを追加
 export const useParsedQueryValue = <
   Patterns extends readonly AllowParsedQueryValuePattern[]
 >(
   queryKey: string,
   patterns: Patterns
 ) => {
   const [searchParams, setSearchParams] = useSearchParams();
   // 省略

+  const queryStringify = useCallback(
+    (value: MultipleParsedQueryValue<Patterns>) => {
+      const newSearchParams = new URLSearchParams(searchParams);
+
+      let stringifiedValue: string | null = null;
+      for (const pattern of patterns) {
+        if (pattern.type !== value.type) {
+          continue;
+        }
+        if ('stringify' in pattern === false) {
+          stringifiedValue = pattern.type;
+          break;
+        }
+        const str = pattern.stringify(value);
+        stringifiedValue = `${pattern.type}_${str}`;
+        break;
+      }
+
+      if (stringifiedValue != null) {
+        newSearchParams.set(queryKey, stringifiedValue);
+      } else {
+        console.warn('クエリのパターンに合致しませんでした:', value);
+        newSearchParams.delete(queryKey);
+      }
+      return newSearchParams.toString();
+    },
+    [queryKey, patterns, searchParams]
+  );

   return {
     // 省略
+    /**
+     * 対象のパースされたクエリ値を含めてクエリ文字列化する
+     * @param value - パースされたクエリ値
+     */
+    queryStringify,
   };
 };

これをuseSidePeakValueのreturnにも返してあげるようにすると、以下のように使うことができます。

文章ページでuseSidePeakValueを使うように変更
 export const SentencePage: FC<SentencePageProps> = ({ page: propPage }) => {
   const { page: paramPage } = useParams();

   const page = propPage ?? parsePageNumber(paramPage) ?? 1;
   const sentence = SENTENCES[page - 1];

   const { currentValue } = useSidePeakContextValue();
+  const { queryStringify } = useSidePeakValue();

   return (
     <Box>
       <Typography variant="h6">文章(ページ{page})</Typography>
       <Typography>{sentence ?? '文章が見つかりませんでした'}</Typography>
       <Stack sx={{ pt: 1 }} direction="row" justifyContent="space-between">
         {page > 1 ? (
           <Button
             component={NavLink}
             to={
               currentValue != null
-                ? `?sidePeak=sentence&page=${page - 1}`
+                ? '?' + queryStringify({ type: 'sentence', page: page - 1 })
                 : `/sentences/${page - 1}`
             }
           >
             前へ
           </Button>
          ) : (
            <Box />
          )}
         {page < SENTENCES.length ? (
           <Button
             component={NavLink}
             to={
               currentValue != null
-                ? `?sidePeak=sentence&page=${page + 1}`
+                ? '?' + queryStringify({ type: 'sentence', page: page + 1 })
                 : `/sentences/${page + 1}`
             }
           >
             次へ
           </Button>
         ) : (
           <Box />
         )}
       </Stack>
     </Box>
   );
 };

ブラウザ履歴をreplaceで更新する

updateParsedQueryValueでデフォルトはブラウザの履歴に追加して更新しますが、replaceで差し替えて更新したい場合もあると思います。setSearchParamsにはNavigateOptionsを渡すことができ、そこにreplace?: booleanがあるのでそれを使うと実現できます。

updateParsedQueryValueでreplacedを渡せるようにする
+import type { NavigateOptions } from 'react-router';
 import { useSearchParams } from 'react-router';

 export const useParsedQueryValue = <
   Patterns extends readonly AllowParsedQueryValuePattern[]
 >(
   queryKey: string,
   patterns: Patterns
 ) => {
   // 省略

   const updateParsedQueryValue = useCallback(
     (
       value: MultipleParsedQueryValue<Patterns> | null,
+      options?: NavigateOptions
     ) => {
       // 実装の中身は省略
+     }, options);
     },
     [queryKey, patterns, setSearchParams]
   );

   return {
     // 省略
   };
 };

終わりに

以上が一つのクエリキーに複数の情報を詰め込んだtype safeなhooksを作る方法でした。パース処理のパターン情報から型を算出するのが結構難しいところですが、ここまで汎用的にできると結構色々なところで応用が効くかなと思いました。クエリのパース周りで毎回実装していて手間だと思っている人の参考になれば幸いです。
最後に検証コードを以下に載せますので、興味がある方はご参照ください。

GitHubで編集を提案

Discussion