🪄

TypeScriptやReactを書くときに意識していること

2024/12/09に公開
2
Changelog
  • 2024/12/10
    • ディレクトリ構造 → ファイル構成
    • useCallbackのコード例を正しくメモ化されるように修正
  • 2024/12/14
    • テストの説明を正しい意図になるように訂正
  • 2024/12/15
    • Reactコンポーネントのファイル構成にUIライブラリの例を追加
    • re-exportによるバンドルサイズ増加の注意を追加
    • React.FCのtree shakingに関する補足情報を訂正&根拠となる引用を追加

※随時更新
サークルドキュメント第一弾📄

はじめに

コードの追加・変更には、高い可読性と保守性が極めて重要です。

本記事では、TypeScriptReactを中心としたコード設計をまとめました。
重要度と抽象度の高いものから紹介します。

ChatGPTなどのAIを活用する場合においても、指示を出す人間がコードを読み、それらを説明する必要があります。この点でも、コードの可読性と保守性を高く保つことは重要です。

カスタムエラー

モジュールでの例外スローは、エラーの出所を分かりやすくするために、専用エラークラスの定義を推奨します。
ここでは総じて「カスタムエラー」としていますが、これはErrorクラスを継承して定義します。

例えば、以下はファイル操作のカスタムエラーです。

file/error.ts
export class FileOperationError extends Error {
  constructor(
    message: string,
    public readonly originalError: Error
  ) {
    super(message);
    this.name = "FileOperationError";
  }
}

このカスタムエラーは以下のように使います。

file/reader.ts
import { readFileSync } from "fs";
import { join } from "path";
import { FileOperationError } from "./error";
import type { FileOperationResult } from "./types";

/**
 * ファイルの内容を取得する
 * @param filePath - 検索対象のファイルパス (相対パス)
 * @returns ファイルの内容
 */
export function readFileContent(filePath: string): FileOperationResult<string> {
  try {
    const absoluteFilePath = join(process.cwd(), filePath);
    // ファイルの内容を取得
    const fileContent = readFileSync(absoluteFilePath, {
      encoding: "utf-8",
      flag: "r",
    });
    return {
      success: true,
      data: fileContent,
    };
  } catch (error) {
    const message = error instanceof Error ? error.message : "不明なエラーが発生しました";
    const fileError = new FileOperationError(
      `ファイル操作に失敗しました: ${message}`,
      error instanceof Error ? error : new Error(String(error))
    );
    return {
      success: false,
      error: fileError,
    };
  }
}

これにより、スローされたインスタンスから何のエラーなのかを判別できます。
最も重要なポイントはinstanceofですが、後述の章:ハンドラーにて具体例を交えながら説明します。

ハンドラー

モジュールでの例外スローや特定のオブジェクトを返す際は、共通処理のハンドラー化を推奨します。

例えば、以下はAPIエラーのハンドラーです。

api/error.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import type { APIErrorResponse } from "./types";

/**
 * APIエラー時のレスポンスを生成する
 * @param error - エラーインスタンス
 * @returns `NextResponse`
 * @example
 * ```ts
 * try {
 *   // API処理
 * } catch (error) {
 *   return handleAPIError(error);
 *   // APIError => { type: "error", error: { message: error.message, code: error.code }, status: error.status }
 *   // ZodError => { type: "error", error: { message: "入力値が不正です", details: error.errors }, status: 400 }
 *   // OtherError => { type: "error", error: { message: "サーバーエラーが発生しました" }, status: 500 }
 * }
 * ```
 */
export function handleAPIError(
  error: unknown
): ReturnType<typeof NextResponse.json<APIErrorResponse>> {
  if (error instanceof APIError) {
    return NextResponse.json(
      {
        type: "error",
        error: {
          message: error.message,
          code: error.code,
        },
        status: error.status,
      },
      { status: error.status }
    );
  }
  if (error instanceof z.ZodError) {
    return NextResponse.json(
      {
        type: "error",
        error: {
          message: "入力値が不正です",
          details: error.errors,
        },
        status: 400,
      },
      { status: 400 }
    );
  }
  return NextResponse.json(
    {
      type: "error",
      error: { message: "サーバーエラーが発生しました" },
      status: 500,
    },
    { status: 500 }
  );
}

このハンドラーは以下のように使います。

app/api/route.ts
import type { NextRequest } from "next/server";
import { FooSchema } from "@/models";
import {
  APIError,
  handleAPIError,
  handleAPISuccess,
} from "@/server";

export async function POST(request: NextRequest) {
  try {
    if (!isValidRequest) {
      return handleAPIError(new APIError("不正なリクエストです", 401)); // instanceof APIError
    }
    const body = await request.json();
    const validatedData = FooSchema.parse(body); // instanceof z.ZodError
    return handleAPISuccess(validatedData);
  } catch (error) {
    return handleAPIError(error);
  }
}
カスタムエラー (APIError)
api/error.ts
/**
 * APIエラー用のクラス
 * @example
 * ```ts
 * try {
 *   if (true) {
 *     // カスタムエラーメッセージのレスポンスを返す
 *     throw new APIError("エラーメッセージ", 403, "ERROR_CODE");
 *   }
 * } catch (error) {
 *   return handleAPIError(error);
 *   // => { type: "error", error: { message: "エラーメッセージ", code: "ERROR_CODE" }, status: 403 }
 * }
 * ```
 */
export class APIError extends Error {
  constructor(
    message: string,
    public status: number = 400,
    public code?: string
  ) {
    super(message);
    this.name = "APIError";
  }
}
API成功ハンドラー (handleAPISuccess)
api/response.ts
import { NextResponse } from "next/server";
import type { APISuccessResponse } from "./types";

/**
 * API成功時のオブジェクトを生成する
 * @param data - レスポンスデータ
 * @returns `APIResponse`
 * @example
 * ```ts
 * const response = createAPIResponse({ message: "Hello, World!" });
 * // => { type: "success", data: { message: "Hello, World!" }, status: 200 }
 * ```
 */
export function createAPIResponse<T>(data: T): APISuccessResponse<T> {
  return { type: "success", data, status: 200 };
}

/**
 * API成功時のレスポンスを生成する
 * @param data - レスポンスデータ
 * @returns `NextResponse`
 * @example
 * ```ts
 * try {
 *   // API処理
 *   return handleAPISuccess(data);
 *   // => { type: "success", data: data, status: 200 }
 * } catch (error) {
 *   return handleAPIError(error);
 * }
 * ```
 */
export function handleAPISuccess<T>(
  data: T
): ReturnType<typeof NextResponse.json<APISuccessResponse<T>>> {
  return NextResponse.json(createAPIResponse(data), { status: 200 });
}

カスタムエラーの真価は、エラー種別による処理の分岐(instanceof)にあります
また、共通処理をハンドラーにすることで、可読性と保守性が大幅に向上します。

判別可能なユニオン型

モジュールやAPIの戻り値は判別可能なユニオン型を推奨します。

前述の章:カスタムエラーで紹介した例をもとに説明します。

例:ファイルの内容を読み取る (readFileContent)
file/reader.ts
import { readFileSync } from "fs";
import { join } from "path";
import { handleFileError } from "./error";
import type { FileOperationResult } from "./types";

/**
 * ファイルの内容を取得する
 * @param filePath - 検索対象のファイルパス (相対パス)
 * @returns ファイルの内容
 */
export function readFileContent(filePath: string): FileOperationResult<string> {
  try {
    const absoluteFilePath = join(process.cwd(), filePath);
    // ファイルの内容を取得
    const fileContent = readFileSync(absoluteFilePath, {
      encoding: "utf-8",
      flag: "r",
    });
    return {
      success: true,
      data: fileContent,
    };
  } catch (error) {
    const fileError = handleFileError(error, "read file");
    return {
      success: false,
      error: fileError,
    };
  }
}

戻り値の型は以下です。

file/types.ts
import type { FileOperationError } from "./error";

type FileOperationSuccess<T> = {
  success: true;
  data: T;
};

type FileOperationFailure = {
  success: false;
  error: FileOperationError;
};

// 判別可能なユニオン型
export type FileOperationResult<T> =
  | FileOperationSuccess<T>
  | FileOperationFailure;

ファイル操作が成功/success: trueの場合は戻り値のオブジェクトにdata: T(ファイルの内容)を含み、失敗/success: falseの場合はerror: FileOperationError(カスタムエラーインスタンス)を含めます。

これにより、以下が実現できます。

import { readFileContent } from "@/server";

const content = readFileContent("/pathto/foo/bar.md");
// ファイルの読み込みに失敗
if (!content.success) {
  return console.error(content.error);
}
// ファイルの内容を表示
console.log(content.data);

content.successの真偽により、他のプロパティの存在が保証されます
判別可能なユニオン型を使わない場合は、以下のようなコードになります。

判別可能なユニオン型がない世界線
file/types.ts
export type FileOperationResult<T> = {
  success: boolean;
  data?: T;
  error?: FileOperationError;
};
// 全てのプロパティの有無を確かめる
if (!content.success || !content.data || !!content.error) {
  return console.error(content.error);
}
console.log(content.data);

判別可能なユニオン型がある世界線でよかったです。
型安全でラクしちゃいましょう。

ファイルを分ける

ファイルの分け方は設計・アーキテクチャによりますが、可読性や保守性を最低限考慮した方法を紹介します。

TypeScriptのモジュールでは、以下のように分けると役割を判別しやすくなります。

  • foo/(関数名|カテゴリ名).ts: 処理系
  • foo/error.ts: カスタムエラークラスやエラーハンドラー
  • foo/types.ts: 型
  • foo/index.ts: 上記をfooモジュールとしてexport

ファイル名は必ず意味を持たせましょう
これにより、モジュールのindex.tsから辿ることができます。

foo/index.ts
export * from "./(関数名|カテゴリ名)"; // 大文字のファイル名は嫌われる傾向にある/ハイフン`-`繋ぎが好まれる
export * from "./error";
export type * from "./types";

Reactのコンポーネントでは、以下のように分けるとビューとビジネスロジックを分離できます。

  • bar/bar.tsx: 親コンポーネント (default export)
  • bar/bar.hooks.ts: Hooks (もしくはuse-bar.ts)
  • bar/bar-baz.tsx: 子コンポーネント (default export)
  • bar/bar-qux.tsx: 子コンポーネント (default export)
  • bar/index.ts: 上記をbarコンポーネントとしてexport
bar/index.ts
import Bar from "./bar";
import BarBaz from "./bar-baz";
import BarQux from "./bar-qux";

// フック
export { useBar } from "/bar.hooks";
// コンポーネント
export { Bar, BarBaz, BarQux };

Reactコンポーネントをdefault exportにする理由は、後述の章:Inline Exportにて説明します。

Reactコンポーネントのファイル構成は、Chakra UIをはじめとするUIライブラリ(NextUI, Yamada UI)を元にしています。

テスト

プロジェクトによりますが、テストは実装ファイルと同じディレクトリに置くと、どのモジュールのテストかを見分けやすくなるかもしれません。
依存関係が広範囲に及ぶテストは、src外のtestディレクトリに置くことも良いでしょう。

前述の章:ファイルを分けるを例として取り上げると、以下のようになります。

  • foo/(関数名|カテゴリ名).ts: 処理系
  • foo/error.ts: カスタムエラークラスやエラーハンドラー
  • foo/types.ts: 型
  • foo/index.ts: 上記をfooモジュールとしてexport
  • foo/(関数名|カテゴリ名).test.ts: テスト ← NEW✨

名前空間 (namespace)

React.~のようにモジュールとしてexportしたい場合は、名前空間を上手く活用しましょう。
ただし、ESLintのルールによっては@typescript-eslint/no-namespaceに怒られるかもしれません。実際に使用するかはプロジェクトに合わせてください。

以下はDiscordモジュールの例です。

discord/index.ts
import * as webhook from "./webhook";

/**
 * Discord 関連のユーティリティ
 * @example
 * ```ts
 * import { Discord } from "@/server";
 * Discord.Webhook.executeWebhook();
 * ```
 */
export namespace Discord {
  export import Webhook = webhook;
}

Inline Export

TypeScriptのexportは 2通り×2種類 あります。
組み合わせは (inline-export|separate-export)×(default-export|named-export) です。

特別な理由がない限り、inline-exportを推奨します。
どれがexportされているのかを見分けやすくなります。

✅inline-export
export const foo = "foo";
export const bar = "bar";
export type Baz = {
  qux: string;
};
export interface Quux {
  corge: string;
}
❌separate-export
const foo = "foo";
const bar = "bar";
type Baz = {
  qux: string;
};
interface Quux {
  corge: string;
}
// ここのコード量によってはどれがexportされているのか見分けづらい
export { foo, bar };
export type { Baz, Quux };

ただし、一部例外があります。後述の章:forwardRefにて説明します。

ES Modules (ESM) には、2種類のexport (default-exportnamed-export) があります。これらは頻繁に話題となっており、時には派閥間の激しい対立が見受けられることもあります。

個人的には、関数や定数など名前で機能や役割を説明しているものnamed-export、Reactコンポーネントなど使う側が役割を決めるものdefault-exportを推奨します。
Reactの代表的なフレームワークであるNext.jsは上記のような設計になっています。

引数 (Params)

関数の引数はオブジェクト形式が推奨です。
必須な引数が2個以上の場合はそのすべてをオブジェクト、第一引数のみ必須の場合は第二引数以降をオブジェクト(オプショナル)にしています。
これにより、どの引数に何の値を渡せば良いかを判断しやすくなります。

以下は後者の例です。

/**
 * 文字列の文字数を正確にカウント (絵文字やサロゲートペアに対応)
 * @param str - 文字列
 * @param locales - ロケール (デフォルト: `ja-JP`)
 * @returns 文字数
 * @example
 * ```ts
 * countGraphemes("👩‍👩‍👧‍👦"); // => 1
 * countGraphemes("👩‍👩‍👧‍👦", { locales: "en-US" }); // => 1
 * ```
 */
export function countGraphemes(
  str: string,
  options: {
    locales?: string;
  } = {}
): number {
  const { locales = "ja-JP" } = options;
  const segmenter = new Intl.Segmenter(locales, { granularity: "grapheme" });
  return [...segmenter.segment(str)].length;
}

オプショナルとして扱う引数は、省略できるように必ずデフォルト値を設定してください
また、デフォルト値を引数に書くこともできますが、引数の増加に比例して読みづらくなります。特別な理由がない限り、引数とデフォルト値を分けて定義しましょう。

デフォルト値を引数に書くパターン(非推奨)
export function countGraphemes(
  str: string,
  { locales }: { locales?: string } = { locales: "ja-JP" }
): number {
  const segmenter = new Intl.Segmenter(locales, { granularity: "grapheme" });
  return [...segmenter.segment(str)].length;
}

forwardRef

inputなどのReactコンポーネントではforwardRefの使用が必要不可欠です。

以下は使用例です。

text-field/text-field.tsx
import { forwardRef } from "react";

export interface TextFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
  error?: string;
}

const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
  ({ children, id, error, ...props }, ref) => {
    return (
      <div className="grid gap-1.5">
        <label htmlFor={id}>{children}</label>
        <input ref={ref} id={id} {...props} />
        <small className="text-red-600">{error}</small>
      </div>
    );
  }
);

TextField.displayName = "TextField";

export default TextField;

必要不可欠である理由

Reactアプリケーションでは、クライアントサイドでのフォームバリデーションを実装する際、ボイラープレートの多さやZodとの親和性から、React Hook Form(以下、RHF)が広く採用されています。

以下は、RHFが提供するuseFormフックの使用例です。

send/form.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { type Foo, FooSchema } from "@/models";

export function Form() {
  // RHF の `useForm` フック
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<Foo>({
    resolver: zodResolver(FooSchema), // Zodで定義したスキーマでフォームバリデーション
    mode: "all",
    defaultValues: undefined,
  });

  // 送信処理
  const onSubmit = (data: Foo) => {
    console.log(data);
    // => { email: "foo@bar.com", name: "Foo Bar" }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <section className="grid gap-1.5">
        <label htmlFor="email-id">メールアドレス</label>
        <input type="email" id="email-id" {...register("email")} />
        <small className="text-red-600">{errors.email?.message}</small>
      </section>
      <section className="grid gap-1.5">
        <label htmlFor="name-id">お名前</label>
        <input type="text" id="name-id" {...register("name")} />
        <small className="text-red-600">{errors.name?.message}</small>
      </section>
      <button type="submit">送信</button>
    </form>
  );
}

RHFのuseFormフックが提供するregister関数は、内部でrefを活用してDOM要素を直接操作します。これにより、フォームのバリデーションを高いパフォーマンスで実現することができます。

refを使用したDOM要素への直接アクセスは、フォームの入力値を効率的に監視する上で重要な役割を果たしています。この仕組みにより、不要な再レンダリングを避けながら、リアルタイムでの入力値の検証が可能です。

しかし、React 18においては、コンポーネントがrefを引数として直接受け取ることはできません。この制約に対応するため、forwardRefを使用することで、refを適切にコンポーネント間で受け渡すことが可能となります。

より高度なバリデーションを実装する際は、RHFが提供するuseControllerフックを活用することができます。

ただし、このフックを使用するとControlled Componentになるため、パフォーマンスの最適化が重要となります。具体的には、React.memoを使用してコンポーネントをメモ化し、不要な再レンダリングを防ぐ必要があります。

注意事項

React.FC

Reactコンポーネントでは、アロー関数でコンポーネントを定義する場合に、可読性からReact.FCの使用を推奨します。

以下はfunctionアロー関数アロー関数+React.FCの例です。
比較のため、同義(記法の違いのみ)になります。

function
export default function Foo({
  children,
  bar,
  baz,
  ...props
}: FooProps): React.ReactNode {
  return (
    <>
      <div>{bar}</div>
      <div {...props}>{children}</div>
      <div>{baz}</div>
    </>
  );
}
アロー関数
export default const Foo = ({ children, bar, baz, ...props }: FooProps): React.ReactNode => {
  return (
    <>
      <div>{bar}</div>
      <div {...props}>{children}</div>
      <div>{baz}</div>
    </>
  );
};
アロー関数 + React.FC
export default const Foo: React.FC<FooProps> = ({ children, bar, baz, ...props }) => {
  return (
    <>
      <div>{bar}</div>
      <div {...props}>{children}</div>
      <div>{baz}</div>
    </>
  );
};

なお、前述の章:forwardRefで紹介したFormは特定のフォームでのみ使用する、抽象度の低いコンポーネントのためfunctionで定義しています。

一部で「React.FCはtree shakingが効かないためFCを個別でインポートするべき」という主張を目にしますが、React.FC型定義ファイル.d.tsを参照するため、実際のバンドルサイズに違いはありません。

Memo化

Reactでは、不具合やパフォーマンスの低下を避けるためにメモ化が重要になります。
特にuseMemouseCallbackは積極的に使用しましょう。

不要な再レンダリングの防止
const Counter: React.FC = () => {
  const [count, setCount] = useState(0);

  // 関数をメモ化
  const handleClick = useCallback(() => {
    console.log("clicked");
  }, []); // <= 依存配列が空なので、関数は1度だけ作成される

  return (
    <>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
      {/* ↓ 関数(プロパティ)のメモ化により再レンダリングを防ぐ */}
      <CustomButton onClick={handleClick} />
      {/* onClick={() => handleClick()} では新しい関数オブジェクトが生成されてしまう */}
    </>
  );
};
無限ループの防止
const Component: React.FC = () => {
  const [data, setData] = useState([]);

  // オブジェクトをメモ化
  const options = useMemo(() => ({
    headers: { "Content-Type": "application/json" }
  }), []); // <= 依存配列が空なので、オブジェクトは1度だけ作成される

  useEffect(() => {
    fetch("/api/data", options).then((res) => setData(res.data));
  }, [options]); // <= `options`はメモ化されているので無限ループを回避できる

  return (
    <ul>
      {data.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
};
パフォーマンスの向上
const DataGrid: React.FC<DataGridProps> = ({ items }) => {
  // 重い計算をメモ化
  const processedData = useMemo(() => {
    return items.map((item) => {
      return complexCalculations(item);
    });
  }, [items]); // <= `items`が変更された時のみ再計算

  return (
    <ul>
      {processedData.map((item) => (
        <li key={item.id}>{item.value}</li>
      ))}
    </ul>
  );
};

ただし、プリミティブな値のみを扱う単純なコンポーネントでのメモ化は非推奨です。
メモ化により可読性が低下してしまいます。可読性とパフォーマンスのバランスが重要です。

単純なコンポーネント
const Button: React.FC<ButtonProps> = ({ text }) => {
  return <button>{text}</button>;
};

また、メモ化した際は別のファイルにカスタムHooksとしてまとめ、前述の章:ファイルを分けるにて紹介したファイル構成(Reactコンポーネント)のように、UIコンポーネント(ビュー)とビジネスロジックの分離を推奨します。

おわりに

随時追加していきます。

アプリ開発サークル@IPUT

Discussion

みたゆうきみたゆうき

このようにラップしてしまうとメモ化の効果はなくなってしまいます。

<CustomButton onClick={() => handleClick()} />

正しくは

<CustomButton onClick={handleClick} />
wiycowiyco

ご指摘ありがとうございます!
正しいコードに修正いたしました。