はじめに
フロントエンドにReactを使っているプロジェクトにアサインされており、少しずつキャッチアップをしています。
今回は、React Routerのチュートリアルを見ながら、TypeScriptで書いてみました。
React Router チュートリアル
公式のチュートリアルは、JavaScriptで書かれており、初見ではTypeScriptでどう書けばいいのかわからない部分がありました。
ググったところ、以下の記事でTypeScriptで解説されており、勉強になりました。
ただ一部省略しているため、VSCode上でエラーになっていたので、もう少し型を書いて見ました。
前提
Qiitaのチュートリアルのほうには、StackBlitzでサンプルコード用意されていました。
今回は、チュートリアルの実装が一通り完了している サンプルコード07 をベースにして、エラーとなっている部分を中心に型を書いてみました。
- React Routerに関係ない警告やエラーは、今回は関係ないので、無視しています。
- 実務は考慮していないので、コードの分割は基本的にはしていません。
実装
型を書いた後のコードは、以下にあげてあります。
react-router-tutorial-ts
主に以下の流れで作業しました。
基本的に型を書くところは、App.tsxのloader
/action
の部分です。
- ViteでReact TypeScriptでプロジェクト作成
- 必要な追加ライブラリをインストール
- サンプルコード07からコードを移植とファイルの整理
- 一旦動くように修正
- 型を書き直していく
rootLoader
まずは、rootLoader
から修正していきます。
rootLoader
の修正前は以下です。
- 引数で
request
を分割代入で受け取っている - 戻り値は、
- API(
getContact
)から返ってきたcontacts
(ContactType[]) - クエリパラメータから取得した
q
(string | null)
- API(
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const q = url.searchParams.get("q");
const contacts = await getContacts(q);
console.log("contacts:", contacts);
return { contacts, q };
}
型を追加で書くと、以下のようになりました。
まずは型の定義からです。
q
は、string | null
で返ってきますが、getContact
の引数にあわせて、string | undefined
にしています。
export type RootLoaderResult = {
contacts: ContactType[];
q?: string;
};
引数の型を明示的に書くために、分割代入をやめて、args
のまま受け取るようにしてみました。
戻り値は、先ほど定義したRootLoaderResult
を、async
関数なので、Promise
で囲ってやります。
export const loader: LoaderFunction = async (
args: LoaderFunctionArgs
): Promise<RootLoaderResult> => {
const url = new URL(args.request.url);
const q = url.searchParams.get("q") ?? undefined;
const contacts = await getContacts(q);
const result: RootLoaderResult = { contacts, q };
return result;
};
LoaderFunction
を定義を確認してみます。
引数であるargs
の型LoaderFunctionArgs<Context>
は以下のように定義されています。
修正前のコードで、loader
が分割代入で受け取っている引数は、args
であることがわかります。
(handlerCtx
, hydrate
は使っていないので、無視しています)
戻り値は、DataFunctionReturnValue
となっており、DataFunctionValue
かPromiseで囲ったものとなります。
DataFunctionValue
は、以下のユニオン型となっています。
Response
NonNullable<unknown>
null
よって、rootLoader
は、NonNullable<unknown>
を返しているということのようです。
/**
* Route loader function signature
*/
export type LoaderFunction<Context = any> = {
(args: LoaderFunctionArgs<Context>, handlerCtx?: unknown): DataFunctionReturnValue;
} & {
hydrate?: boolean;
};
/**
* Arguments passed to loader functions
*/
export interface LoaderFunctionArgs<Context = any> extends DataFunctionArgs<Context> {
}
/**
* @private
* Arguments passed to route loader/action functions. Same for now but we keep
* this as a private implementation detail in case they diverge in the future.
*/
interface DataFunctionArgs<Context> {
request: Request; //★
params: Params;
context?: Context;
}
/**
* Loaders and actions can return anything except `undefined` (`null` is a
* valid return value if there is no data to return). Responses are preferred
* and will ease any future migration to Remix
*/
type DataFunctionValue = Response | NonNullable<unknown> | null;
type DataFunctionReturnValue = Promise<DataFunctionValue> | DataFunctionValue;
あとは、rootLoader
の結果を受け取っているuseLoaderData()
を修正します。
修正箇所は主に2箇所です。
型を明示したため、その後のコードで少し調整が必要ですが、コミットログを見ていただければわかるかと思います。
- const { contacts } = useLoaderData() as { contacts: ContactType[] };
+ const { contacts } = useLoaderData() as RootLoaderResult;
- const { q } = useLoaderData();
+ const { q } = useLoaderData() as RootLoaderResult;
rootAction
次はrootAction
です。
修正前のコードは以下です。
- 引数はなし
- 戻り値は、
redirect
関数の戻り値をそのまま返しています。
export const action: ActionFunction = async () => {
const contact = await createContact();
return redirect(`/contacts/${contact.id}/edit`);
};
修正後は以下のようにしました。
引数は不要ですが、一応コメントアウトして書いてみました。
export const action: ActionFunction =
async (/*args: ActionFunctionArgs*/): Promise<Response> => {
const contact = await createContact();
return redirect(`/contacts/${contact.id}/edit`);
};
ActionFunction
の定義を見てみます。
引数には、args
とhandlerCtx
がわたってきますが、使っていないので省略していることがわかります。
戻り値は、LoaderFunction
と同じです。
今回はasync
関数からredirect
の戻り値を返しているので、Promise<Response>
となります。
/**
* Route action function signature
*/
export interface ActionFunction<Context = any> {
(args: ActionFunctionArgs<Context>, handlerCtx?: unknown): DataFunctionReturnValue;
}
/**
* Arguments passed to action functions
*/
export interface ActionFunctionArgs<Context = any> extends DataFunctionArgs<Context> {
}
/**
* @private
* Arguments passed to route loader/action functions. Same for now but we keep
* this as a private implementation detail in case they diverge in the future.
*/
interface DataFunctionArgs<Context> {
request: Request;
params: Params;
context?: Context;
}
/**
* Loaders and actions can return anything except `undefined` (`null` is a
* valid return value if there is no data to return). Responses are preferred
* and will ease any future migration to Remix
*/
type DataFunctionValue = Response | NonNullable<unknown> | null;
type DataFunctionReturnValue = Promise<DataFunctionValue> | DataFunctionValue;
App.tsxのcontactLoader
基本的にやることは同じです。
loader
の戻り値の型を定義します。
export type ContactLoaderResult = {
contact: ContactType;
};
export const loader: LoaderFunction<Params> = async ({
params: { contactId },
}) => {
const contact = await getContact(contactId);
if (!contact) {
throw new Response("", {
status: 404,
statusText: "Not Found",
});
}
return { contact };
};
export const loader: LoaderFunction = async (
args: LoaderFunctionArgs
): Promise<ContactLoaderResult> => {
const contact = await getContact(args.params.contactId);
if (!contact) {
throw new Response("", {
status: 404,
statusText: "Not Found",
});
}
const result: ContactLoaderResult = {
contact,
};
return result;
};
注意する点は、修正前のコードでは、LoaderFuction
のジェネリクスにParams
を指定していますが、修正後はなくなっているという点です。
型の定義を見ると、Context
はcontext
の型であり、params
の型を指定できるものではなさそうです。args.params
のcontactId
は、エディタの補完は効きませんが、エラーにもなりません。
ちなみに、params
の型はParams
となっており、任意のキーで値がstring | undefined
となるようです。
/**
* @private
* Arguments passed to route loader/action functions. Same for now but we keep
* this as a private implementation detail in case they diverge in the future.
*/
interface DataFunctionArgs<Context> {
request: Request;
params: Params; //★
context?: Context;
}
/**
* The parameters that were parsed from the URL path.
*/
export type Params<Key extends string = string> = {
readonly [key in Key]: string | undefined;
};
あとは、useLoaderData()
で受け取っているところを修正します。
- const { contact } = useLoaderData() as { contact: ContactType };
+ const { contact } = useLoaderData() as ContactLoaderResult;
contactをここまで分割しなくていいかなと思い、以下のようにしました。
- const {
- contact: { first, last, twitter, avatar, notes },
- } = useLoaderData() as { contact: ContactType };
+ const { contact } = useLoaderData() as ContactLoaderResult;
contactAction
rootAction
と基本的に同じですが、contactAction
には引数があります。
- request
- contactId
また戻り値は、updateContact
関数の戻り値であるContactType
をPromise
でラップして返します。
export const action = async ({ request, params: { contactId } }) => {
const formData = await request.formData();
return updateContact(contactId, {
favorite: formData.get("favorite") === "true",
});
};
修正すると以下のようになります。
引数は、ActionFunction
の引数であるActionFunctionArgs
をそのまま受け取るようにしました。
export const action: ActionFunction = async (
args: ActionFunctionArgs
): Promise<ContactType> => {
const formData = await args.request.formData();
const contactId = args.params.contactId as string;
return updateContact(contactId, {
favorite: formData.get("favorite") === "true",
});
};
editAction
修正前のeditAction
は以下のようになっています。
まずcontactLoader
同様に、ジェネリクスで指定するのは、params
の型ではないので、記載不要です。
引数は、contactAction
と同じです。
戻り値は、Promise<Response>
です。
export const action: ActionFunction<{
request: Request;
params: Params;
}> = async ({ request, params: { contactId } }) => {
const formData = await request.formData();
const updates = Object.fromEntries(formData);
await updateContact(contactId, updates);
return redirect(`/contacts/${contactId}`);
};
export const action: ActionFunction = async (
args: ActionFunctionArgs
): Promise<Response> => {
const formData = await args.request.formData();
const updates = Object.fromEntries(formData);
const contactId = args.params.contactId as string;
await updateContact(contactId, updates);
return redirect(`/contacts/${contactId}`);
};
destroyAction
これまでと基本的に同じなので、修正前後のコードだけ記載しておきます
export const action: ActionFunction<Params> = async ({
params: { contactId },
}) => {
// throw new Error('oh dang!');
if (contactId) {
await deleteContact(contactId);
}
return redirect("/");
};
export const action: ActionFunction = async (
args: ActionFunctionArgs
): Promise<Response> => {
// throw new Error('oh dang!');
const contactId = args.params.contactId;
if (contactId) {
await deleteContact(contactId);
}
return redirect("/");
};
まとめ
React RouterのチュートリアルをTypeScriptで型を書きながら書いてみました。
もともとC/C++/C#を触っていたこともあり、型をしっかり書きながらのほうが理解しやすいなと改めて思いました。
React Routerは、もう少し型をしっかり扱えるようなインターフェースにしてくれると嬉しいです。