React RouterでSearch Paramsを使った状態管理を楽にするhooksを作ってみた
始めに
ダイアログの開閉フラグやタブの表示場所などの状態をuseState
で管理することが多いと思いますが、これをすると画面をリロードしたりURLを共有しても初期状態が表示されてしまい、状態を保存することができません。URLの後ろに ?open=true
みたいなパラメータを仕込み、それを元にすると目的は果たされますが、型が当たらないのと実装がそこそこ冗長になり、これを毎回書くのは億劫です。
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になります😄 次からいくつか例を挙げていきます。
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
*/
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に書き変えるだけで実装できます😊
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
*/
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が決まっている場合の型推論も上手くできております😊
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型で取得したい場合は以下のように書きます。
const Page = () => {
// 変な型推論がされてしまうため、<string>と型を明記する
const [keyword, setKeyword] = setStringStateBySearchParams<string>({
key: 'keyword',
defaultValue: ''
})
}
オブジェクトをSearchParamsで管理するhooks
オブジェクトの場合はzodなどのスキーマを用意することで楽に型推論やパース処理が行えるようになります。
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
と同じくらいの使用感にまでシンプルにしたことでロジックの見通しも良いですね😊
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にしたい場合はsetSearchParams
にreplace
オプションを追加するだけでOKです。これをラップしているhooks側でもreplace
オプションを提供してバケツリレーすることでreplaceで更新できるようになります。
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で状態管理する際の参考になれば幸いです。
Discussion