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


[TSチュートリアル1]: https://qiita.com/FumioNonaka/items/ffa6fbd25fe3d7a40088
[TSチュートリアル2]: https://qiita.com/FumioNonaka/items/5027da3d6748eac7f060


## はじめに

フロントエンドにReactを使っているプロジェクトにアサインされており、少しずつキャッチアップをしています。  
今回は、React Routerのチュートリアルを見ながら、TypeScriptで書いてみました。  
[React Router チュートリアル](https://reactrouter.com/en/main/start/tutorial)

公式のチュートリアルは、JavaScriptで書かれており、初見ではTypeScriptでどう書けばいいのかわからない部分がありました。  
ググったところ、以下の記事でTypeScriptで解説されており、勉強になりました。
ただ一部省略しているため、VSCode上でエラーになっていたので、もう少し型を書いて見ました。

* [React + React Router + TypeScript: チュートリアル[01]][TSチュートリアル1]
* [React + React Router + TypeScript: チュートリアル[02]][TSチュートリアル2]

## 前提

Qiitaのチュートリアルのほうには、StackBlitzでサンプルコード用意されていました。  
今回は、チュートリアルの実装が一通り完了している [サンプルコード07](https://stackblitz.com/edit/stackblitz-starters-ycxk9t?file=src%2FApp.tsx) をベースにして、エラーとなっている部分を中心に型を書いてみました。

* React Routerに関係ない警告やエラーは、今回は関係ないので、無視しています。
* 実務は考慮していないので、コードの分割は基本的にはしていません。

## 実装

型を書いた後のコードは、以下にあげてあります。
[react-router-tutorial-ts](https://github.com/tokoro-it/react-router-tutorial-ts)

主に以下の流れで作業しました。
基本的に型を書くところは、App.tsxの`loader`/`action`の部分です。

* ViteでReact TypeScriptでプロジェクト作成
* 必要な追加ライブラリをインストール
* サンプルコード07からコードを移植とファイルの整理
* 一旦動くように修正
* 型を書き直していく

### rootLoader

まずは、`rootLoader`から修正していきます。
`rootLoader`の修正前は以下です。

* 引数で `request` を分割代入で受け取っている
* 戻り値は、
  * API(`getContact`)から返ってきた`contacts`(ContactType[]) 
  * クエリパラメータから取得した`q`(string | null)

```tsx: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`にしています。

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

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

```tsx: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>`を返しているということのようです。

```ts: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箇所です。
型を明示したため、その後のコードで少し調整が必要ですが、コミットログを見ていただければわかるかと思います。

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

```diff_tsx:components/search-form.tsx
-  const { q } = useLoaderData();
+  const { q } = useLoaderData() as RootLoaderResult;
```

### rootAction

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

* 引数はなし
* 戻り値は、`redirect`関数の戻り値をそのまま返しています。

```tsx:root.tsx(修正前)
export const action: ActionFunction = async () => {
  const contact = await createContact();
  return redirect(`/contacts/${contact.id}/edit`);
};
```

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

```tsx:root.tsx(修正後)
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>`となります。

```ts: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`の戻り値の型を定義します。

```tsx:types/ContactLoaderResult.ts
export type ContactLoaderResult = {
  contact: ContactType;
};
```

```tsx: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 };
};
```

```tsx: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`を指定していますが、修正後はなくなっているという点です。

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

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

```ts: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()`で受け取っているところを修正します。

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

contactをここまで分割しなくていいかなと思い、以下のようにしました。
```diff_tsx:edit.tsx
-  const {
-    contact: { first, last, twitter, avatar, notes },
-  } = useLoaderData() as { contact: ContactType };
+  const { contact } = useLoaderData() as ContactLoaderResult;
```

### contactAction

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

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

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

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

```tsx: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>`です。

```tsx: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}`);
};
```

```tsx: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

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


```tsx:contact.tsx(修正前)
export const action: ActionFunction<Params> = async ({
  params: { contactId },
}) => {
  // throw new Error('oh dang!');
  if (contactId) {
    await deleteContact(contactId);
  }
  return redirect("/");
};
```

```tsx: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は、もう少し型をしっかり扱えるようなインターフェースにしてくれると嬉しいです。