0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React RouterのチュートリアルをTypeScriptで型を意識をしながら書いてみた

Posted at

はじめに

フロントエンドに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)
root.tsx(修正前)
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にしています。

types/RootLoaderResult.ts
export type RootLoaderResult = {
  contacts: ContactType[];
  q?: string;
};

引数の型を明示的に書くために、分割代入をやめて、argsのまま受け取るようにしてみました。
戻り値は、先ほど定義したRootLoaderResultを、async関数なので、Promiseで囲ってやります。

root.tsx(修正後)
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>を返しているということのようです。

node_modules@remix-run\router\dist\utils.d.ts
/**
 * 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箇所です。
型を明示したため、その後のコードで少し調整が必要ですが、コミットログを見ていただければわかるかと思います。

components/contacts.tsx
-  const { contacts } = useLoaderData() as { contacts: ContactType[] };
+  const { contacts } = useLoaderData() as RootLoaderResult;
components/search-form.tsx
-  const { q } = useLoaderData();
+  const { q } = useLoaderData() as RootLoaderResult;

rootAction

次はrootActionです。
修正前のコードは以下です。

  • 引数はなし
  • 戻り値は、redirect関数の戻り値をそのまま返しています。
root.tsx(修正前)
export const action: ActionFunction = async () => {
  const contact = await createContact();
  return redirect(`/contacts/${contact.id}/edit`);
};

修正後は以下のようにしました。
引数は不要ですが、一応コメントアウトして書いてみました。

root.tsx(修正後)
export const action: ActionFunction =
  async (/*args: ActionFunctionArgs*/): Promise<Response> => {
    const contact = await createContact();
    return redirect(`/contacts/${contact.id}/edit`);
  };

ActionFunctionの定義を見てみます。
引数には、argshandlerCtxがわたってきますが、使っていないので省略していることがわかります。
戻り値は、LoaderFunctionと同じです。
今回はasync関数からredirectの戻り値を返しているので、Promise<Response>となります。

node_modules@remix-run\router\dist\utils.d.ts
/**
 * 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の戻り値の型を定義します。

types/ContactLoaderResult.ts
export type ContactLoaderResult = {
  contact: ContactType;
};
contact.tsx(修正前)
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 };
};
contact.tsx(修正後)
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を指定していますが、修正後はなくなっているという点です。

型の定義を見ると、Contextcontextの型であり、paramsの型を指定できるものではなさそうです。args.paramscontactIdは、エディタの補完は効きませんが、エラーにもなりません。

ちなみに、paramsの型はParamsとなっており、任意のキーで値がstring | undefinedとなるようです。

node_modules@remix-run\router\dist\utils.d.ts
/**
 * @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()で受け取っているところを修正します。

contact.tsx
-  const { contact } = useLoaderData() as { contact: ContactType };
+  const { contact } = useLoaderData() as ContactLoaderResult;

contactをここまで分割しなくていいかなと思い、以下のようにしました。

edit.tsx
-  const {
-    contact: { first, last, twitter, avatar, notes },
-  } = useLoaderData() as { contact: ContactType };
+  const { contact } = useLoaderData() as ContactLoaderResult;

contactAction

rootActionと基本的に同じですが、contactActionには引数があります。

  • request
  • contactId

また戻り値は、updateContact関数の戻り値であるContactTypePromiseでラップして返します。

contact.tsx(修正前)
export const action = async ({ request, params: { contactId } }) => {
  const formData = await request.formData();
  return updateContact(contactId, {
    favorite: formData.get("favorite") === "true",
  });
};

修正すると以下のようになります。
引数は、ActionFunctionの引数であるActionFunctionArgsをそのまま受け取るようにしました。

contact.tsx(修正後)
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>です。

contact.tsx(修正前)
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}`);
};
contact.tsx(修正後)
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

これまでと基本的に同じなので、修正前後のコードだけ記載しておきます

contact.tsx(修正前)
export const action: ActionFunction<Params> = async ({
  params: { contactId },
}) => {
  // throw new Error('oh dang!');
  if (contactId) {
    await deleteContact(contactId);
  }
  return redirect("/");
};
contact.tsx(修正後)
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は、もう少し型をしっかり扱えるようなインターフェースにしてくれると嬉しいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?