📚

React RouterでSearch Paramsを使った状態管理を楽にするhooksを作ってみた

2025/01/01に公開

始めに

ダイアログの開閉フラグやタブの表示場所などの状態をuseStateで管理することが多いと思いますが、これをすると画面をリロードしたりURLを共有しても初期状態が表示されてしまい、状態を保存することができません。URLの後ろに ?open=true みたいなパラメータを仕込み、それを元にすると目的は果たされますが、型が当たらないのと実装がそこそこ冗長になり、これを毎回書くのは億劫です。

直接useSearchParamsを使ってフラグの状態を管理する例
import { useSearchParams } from 'react-router';

export const DialogPage = () => {
  const [searchParams, setSearchParams] = useSearchParams();

  const isOpen = searchParams.get('open') === 'true'
  const updateIsOpen = (newIsOpen: boolean) => {
    searchParams((prevSearchParams) => {
      if (newIsOpen === true) {
        prevSearchParams.set('open', true);
      } else {
        prevSearchParams.delete('open');
      }
      return prevSearchParams;
    });
  }

  return (
    //
  )
}

元々やりたいことは状態をローカルステートからURL検索パラメータで管理するだけで、利用する側はuseStateと同じ感覚で扱えたら便利だなと思い、それができるようなhooksを作ってみたので記事にまとめてみました。

useStateと同じインターフェースでSearchParamsを更新するhooksの実装

結論から言うと以下のhooksがSearchParamsを使ったどのケースにも対応できる汎用的なhooksになります。ここから更にラップしてboolean用、string用などを作るとより使いやすいhooksになります😄 次からいくつか例を挙げていきます。

ローカルステートをURL検索パラメータを使って管理するhooks
import { useCallback, SetStateAction } from 'react';
import { useSearchParams } from 'react-router';

/**
 * ローカルステートをURL検索パラメータを使って管理するhooks
 */
export const useStateBySearchParams = <T>({
  parser,
  updater,
}: {
  /**
   * searchParamsから目的の値にパースする
   * @param searchParams - URL検索パラメーターインスタンス
   */
  parser: (searchParams: URLSearchParams) => T;
  /**
   * searchParamsの更新処理
   * @param newValue - 新しい値
   * @param searchParams - URL検索パラメーターインスタンス
   */
  updater: (newValue: T, searchParams: URLSearchParams) => URLSearchParams;
}) => {
  const [searchParams, setSearchParams] = useSearchParams();

  const value = parser(searchParams);

  const setValue = useCallback(
    (valueOrFunc: SetStateAction<T>) => {
      setSearchParams(
        (prevSearchParams) => {
          const prevValue = parser(prevSearchParams);
          const newValue: T =
            typeof valueOrFunc === 'function'
              ? // @ts-ignore 上手く型の絞り込みがされなかったのでignoreする
                valueOrFunc(prevValue)
              : valueOrFunc;

          const newSearchParams = updater(newValue, prevSearchParams);
          return newSearchParams;
        }
      );
    },
    [parser, updater]
  );

  return [value, setValue] as const;
};

フラグをSearchParamsで管理するhooks

先ほど実装した useStateBySearchParams をラップしてフラグ(boolean)に特化したhooksを作ると以下のようになります。これで引数は検索パラメータのキー名を渡すだけで良くなり、とてもシンプルなhooksになります。

booleanステートをURL検索パラメータを使って管理するhooks
/**
 * booleanステートをURL検索パラメータを使って管理するhooks
 */
const useBooleanStateBySearchParams = ({
  key,
}: {
  /** URL検索パラメータを参照するキー */
  key: string;
}) => {
  return useStateBySearchParams({
    parser: (searchParams) => {
      return searchParams.get(key) === 'true';
    },
    updater: (newFlag, searchParams) => {
      if (newFlag === true) {
        searchParams.set(key, 'true');
      } else {
        searchParams.delete(key);
      }
      return searchParams;
    },
  });
};

使用例は以下のようになり、ReturnTypeをuseStateと合わせているため、代わりにこのhooksに書き変えるだけで実装できます😊

SearchParamsでダイアログ開閉フラグの状態管理をする例
 export const DialogPage: FC = () => {
-  const [isOpen, setIsOpen] = useState(false);
+  const [isOpen, setIsOpen] = useBooleanStateBySearchParams({ key: 'open' });

   return (
     <div>
       <h3>ダイアログ画面</h3>
       <Button
         variant="contained"
         onClick={() => {
           setIsOpen(true);
         }}
       >
         開く
       </Button>
       <Dialog
         open={isOpen}
         PaperProps={{
           sx: {
             width: '80%',
           },
         }}
         onClose={() => {
           setIsOpen(false);
         }}
       >
         <DialogTitle>ダイアログ</DialogTitle>
         <DialogContent>コンテンツ</DialogContent>
         <DialogActions>
           <Button
             onClick={() => {
               setIsOpen(false);
             }}
           >
             閉じる
           </Button>
         </DialogActions>
       </Dialog>
     </div>
   );
 }

文字列をSearchParamsで管理するhooks

文字列の場合はstring型だけではなく'tab1' | 'tab2' | 'tab3'のようなユニオンリテラル型にも対応するように書くと以下のようになります。

文字列ステートをURL検索パラメータを使って管理するhooks
/**
 * 文字列ステートをURL検索パラメータを使って管理するhooks
 */
export const useStringStateBySearchParams = function <T extends string>({
  key,
  allowValues,
  defaultValue,
}: {
  /** URL検索パラメータを参照するキー */
  key: string;
  /** 受け入れ可能な値 */
  allowValues?: T[];
  /** デフォルト値 */
  defaultValue: T;
}) {
  return useStateBySearchParams({
    parser: (searchParams): T => {
      const value = searchParams.get(key);
      if (value == null) {
        return defaultValue;
      }
      if (allowValues == null) {
        return value as T;
      }
      return allowValues.includes(value as T) ? (value as T) : defaultValue;
    },
    updater: (newValue, searchParams) => {
      if (newValue === defaultValue) {
        searchParams.delete(key);
      } else {
        searchParams.set(key, newValue);
      }
      return searchParams;
    },
  });
};

使用例は以下のようになり、タブIDが決まっている場合の型推論も上手くできております😊

SearchParamsでタブIDの状態管理をする例
const TAB_ITEMS = [
  { value: 'tab1', label: 'タブ1' },
  { value: 'tab2', label: 'タブ2' },
  { value: 'tab3', label: 'タブ3' },
] as const;
const TAB_IDS = TAB_ITEMS.map((item) => item.value);
type TabId = (typeof TAB_IDS)[number];

export const TabsPage: FC = () => {
  const [tabId, setTabId] = useStringStateBySearchParams({
    key: 'tab',
    allowValues: TAB_IDS,
    defaultValue: 'tab1',
  });

  return (
    <div>
      <h3>タブ画面</h3>
      <Tabs
        value={tabId}
        variant="fullWidth"
        onChange={(_, value: TabId) => {
          setTabId(value);
        }}
      >
        {TAB_ITEMS.map((tabItem) => (
          <Tab
            key={tabItem.value}
            value={tabItem.value}
            label={tabItem.label}
          />
        ))}
      </Tabs>
      <div>tab: {tabId}</div>
    </div>
  );
};

なお、単純にstring型で取得したい場合は以下のように書きます。

単純にstring型で取得する例
const Page = () => {
  // 変な型推論がされてしまうため、<string>と型を明記する
  const [keyword, setKeyword] = setStringStateBySearchParams<string>({
    key: 'keyword',
    defaultValue: ''
  })
}

オブジェクトをSearchParamsで管理するhooks

オブジェクトの場合はzodなどのスキーマを用意することで楽に型推論やパース処理が行えるようになります。

ObjectステートをURL検索パラメータを使って管理するhooks
import { z } from 'zod',
import { isEqual } from 'lodash-es';

/**
 * ObjectステートをURL検索パラメータを使って管理するhooks
 */
const useObjectStateBySearchParams = <Schema extends z.ZodSchema>({
  key,
  schema,
  defaultValue,
}: {
  /** URL検索パラメータを参照するキー */
  key: string;
  /** Zodスキーマ */
  schema: Schema;
  /** デフォルト値 */
  defaultValue: z.infer<Schema>;
}) => {
  return useStateBySearchParams({
    parser: (searchParams): z.infer<Schema> => {
      const value = searchParams.get(key);
      try {
        const json = JSON.parse(value ?? '');
        return schema.parse(json);
      } catch {
        return defaultValue;
      }
    },
    updater: (newValue, searchParams) => {
      if (isEqual(newValue, defaultValue)) {
        searchParams.delete(key);
      } else {
        searchParams.set(key, JSON.stringify(newValue));
      }
      return searchParams;
    },
  });
};

使用例は以下のようになります。スキーマさえ定義すれば何でも対応できるので配列のパターンも書いています。(配列もJSではオブジェクトと認識されるのでここで一緒にやっちゃいます)
オブジェクトになってしかもパラメータが3種類もあって複雑になっていますが、useStateと同じくらいの使用感にまでシンプルにしたことでロジックの見通しも良いですね😊

SearchParamsでキーワードやページネーション、ソートの状態管理をする例
const sortModelSchema = z.array(
  z
    .object({
      field: z.string(),
      sort: z.enum(['asc', 'desc']).nullable().optional(),
    })
    // optionalだけ外してundefinedだけ残すハック
    // @see https://stackoverflow.com/a/74650249
    .transform((obj) => ({ ...obj, sort: obj.sort }))
) satisfies z.ZodType<GridSortModel, any, any>;

const paginationModelSchema = z.object({
  page: z.number(),
  pageSize: z.number(),
}) satisfies z.ZodType<GridPaginationModel, any, any>;
const DEFAULT_PAGINATION_MODEL: GridPaginationModel = {
  page: 0,
  pageSize: 5,
};

export const TablePage: FC = () => {
  const [keyword, setKeyword] = useStringStateBySearchParams<string>({
    key: 'keyword',
    defaultValue: '',
  });
  const [sortModel, setSortModel] = useObjectStateBySearchParams({
    key: 'sortModel',
    schema: sortModelSchema,
    defaultValue: [],
  });
  const [paginationModel, setPaginationModel] = useObjectStateBySearchParams({
    key: 'paginationModel',
    schema: paginationModelSchema,
    defaultValue: DEFAULT_PAGINATION_MODEL,
  });

  const filteredPeople = useMemo(() => {
    return PEOPLE.filter((person) => {
      if (person.id.toString().includes(keyword)) {
        return true;
      }
      if (person.name.includes(keyword)) {
        return true;
      }
      const sexLabel = person.sexType === 'male' ? '男性' : '女性';
      if (sexLabel.includes(keyword)) {
        return true;
      }
      if (person.email.includes(keyword)) {
        return true;
      }
      return false;
    });
  }, [keyword]);

  return (
    <div>
      <h3>Table画面</h3>
      <Box sx={{ mb: 1 }}>
        <DebouncedKeyword
          keyword={keyword}
          onChangeDebouncedKeyword={(newKeyword) => {
            setKeyword(newKeyword);
          }}
        />
      </Box>
      <DataGrid
        rows={filteredPeople}
        columns={columns}
        slotProps={{
          pagination: {
            sx: {
              '& .MuiTablePagination-selectLabel': {
                display: 'block',
              },
              '& .MuiTablePagination-input': {
                display: 'inline-flex',
              },
            },
          },
        }}
        disableColumnFilter
        disableColumnSelector
        sortModel={sortModel}
        onSortModelChange={(newModel) => {
          setSortModel(newModel);
        }}
        pageSizeOptions={[5, 10]}
        paginationModel={paginationModel}
        onPaginationModelChange={(newModel) => {
          setPaginationModel(newModel);
        }}
      />
    </div>
  );
};

URL historyをpushではなくreplaceにしたい場合

これまでのSearchParamsの更新はいわゆるrouter.push的なhistoryを追加する行為でしたが、先ほどのテーブルのような例だとキーワードを変えるたびにpushされるのは微妙ですよね。これをreplaceにしたい場合はsetSearchParamsreplaceオプションを追加するだけでOKです。これをラップしているhooks側でもreplaceオプションを提供してバケツリレーすることでreplaceで更新できるようになります。

pushではなくreplaceでSearchParamsを更新できるようにオプションを追加
 import { useCallback, SetStateAction } from 'react';
 import { useSearchParams } from 'react-router';

 /**
  * ローカルステートをURL検索パラメータを使って管理するhooks
  */
 export const useStateBySearchParams = <T>({
   parser,
   updater,
+  replace,
 }: {
   /**
    * searchParamsから目的の値にパースする
    * @param searchParams - URL検索パラメーターインスタンス
    */
   parser: (searchParams: URLSearchParams) => T;
   /**
    * searchParamsの更新処理
    * @param newValue - 新しい値
    * @param searchParams - URL検索パラメーターインスタンス
    */
   updater: (newValue: T, searchParams: URLSearchParams) => URLSearchParams;
+  /** historyの更新をreplaceするか(未定義の場合はpush) */
+  replace?: boolean;
 }) => {
   const [searchParams, setSearchParams] = useSearchParams();

   const value = parser(searchParams);

   const setValue = useCallback(
     (valueOrFunc: SetStateAction<T>) => {
       setSearchParams(
         (prevSearchParams) => {
           const prevValue = parser(prevSearchParams);
           const newValue: T =
             typeof valueOrFunc === 'function'
               ? // @ts-ignore 上手く型の絞り込みがされなかったのでignoreする
                 valueOrFunc(prevValue)
               : valueOrFunc;

           const newSearchParams = updater(newValue, prevSearchParams);
           return newSearchParams;
         },
+       {
+          replace,
+        }
       );
     },
-    [parser, updater]
+    [parser, updater, replace]
   );

   return [value, setValue] as const;
 };

終わりに

以上がReactRouterでSearch Paramsを使った状態管理を楽にするhooksの実装でした。それぞれの型に合わせたhooksを用意することでuseStateとほぼ同じ感覚で使えるようになって、気軽に使いやすくなったなと感じました😊

最後に検証コードをStackBlitzに置いていて以下に貼っておくので興味がある方は是非ご覧ください。
Search Paramsで状態管理する際の参考になれば幸いです。

GitHubで編集を提案

Discussion