【フロントエンド】表示周りのコードの書き方を考える
フロントエンド開発では「UI上の値がXXのときYYを表示する」といったコードが頻出です。
これをコンポーネントに直接書くとJSXが読みにくくなったり、無限に増殖したりするので一箇所で管理したくなってきます。しかし、色々な書き方が考えられるため、自分の中でも最適解がわからずにいました。
そこで、現時点で考える「こう書きたい」を記事にしてみようと思います。
使用する技術
- React
- TypeScript
- Prisma (スキーマ定義 / TypeScriptコード生成)
- React Hook Form / Zod (フォームの状態管理・バリデーション)
定数
まずは定数について考えていきます。
スキーマ schema.prisma
から以下の様なコードを生成したとしましょう。
export namespace $Enums {
export const Role: {
USER: 'USER',
ADMIN: 'ADMIN',
ROBOT: 'ROBOT'
};
export type Role = (typeof Role)[keyof typeof Role]
データ構造
各ロールに対応するテキストを定義するうえで、特に関心事になるのが「データ構造」です。TypeScriptではオブジェクト (ここでは連想配列と呼びます)、配列、Map辺りを使うことになるのですが、テキストの取り出しやすさを考えると連想配列が適していると思っています。
const roleOptionsArray = [
{ value: "ADMIN", label: "管理者ユーザー" },
{ value: "USER", label: "一般ユーザー" },
{ value: "ROBOT", label: "スクリプト" },
] as const;
const roleOptions = {
[Role.ADMIN]: "管理者ユーザー",
[Role.USER]: "一般ユーザー",
[Role.ROBOT]: "スクリプト",
} as const;
// `find` で検索するため、"管理者ユーザー" | undefined になってしまう
const roleLabel = roleOptionsArray.find((role) => role.value === Role.ADMIN)?.label;
const roleLabel2 = roleOptions[Role.ADMIN]; // "管理者ユーザー"
こちらの記事を参考にさせていただきました。
次に型を厳密にしてみましょう。
ポイントとして、生成された型を使うことで Role
に変更が入った場合にコンパイルエラーで検知できます。
[Role.ADMIN]: "管理者ユーザー",
[Role.USER]: "一般ユーザー",
[Role.ROBOT]: "スクリプト",
-} as const;
+} as const satisfies Record<keyof typeof Role, string>;
使い方 (テキストへのアクセス)
Object.entries
で連想配列を展開して、value
と label
の組み合わせを取り出します。以下のコードでは、ラジオグループの選択肢として使用してみました。
<RadioGroup>
{Object.entries(roleOptions).map(([value, label]) => (
<div key={value} className="flex items-center space-x-2">
<RadioGroupItem
key={value}
value={value}
/>
<Label
className={cx(value === Role.ADMIN && "text-red-500")}
/** ^^^^^ 値の比較にも定数を使うことで、マジックナンバーになることを防ぐ **/
>
{label}
</Label>
</div>
))}
</RadioGroup>
並び順を変えたり、特定の組み合わせを取り除きたい場合、コンポーネント内で定数を再定義します。ここでも satisfies
で型を厳密にすることで、スキーマの変更を伝播させることができます。
const radioGroupRoleOptions = {
[Role.USER]: "一般ユーザー",
[Role.ADMIN]: "管理者ユーザー",
[Role.ROBOT]: "スクリプト",
} as const satisfies typeof roleOptions;
値からテキストへの変換を考えると、UIに連想配列の値を代入することで型の厳密さが失われてしまうことに気づきます ("ADMIN" | "USER" | "ROBOT"
-> "string"
)。
この辺の実装はどこまで厳密にするかの話になりますが、連想配列と値を受け取って、テキストまたは undefined
を返すユーティリティ関数をつくって対応します。
const getOptionLabel = <T extends Record<string | number, string>>(
options: T,
value: T extends Record<string, string> ? string : number
): T[keyof T] | undefined => {
return options[value as keyof T];
};
<form
>
<Controller
control={control}
name="role"
render={({ field }) => (
<RadioGroup
{...field}
onValueChange={(value) => {
const label = getOptionLabel(radioGroupRoleOptions, value);
/** ^^^^^ 返り値は連想配列のテキスト / undefinedに推論される **/
if (!label) {
console.log("unexpected value:", value);
}
console.log(label);
}}
>
</RadioGroup>
)}
/>
</form>
表示条件
続いて条件によるテキストの表示・非表示の切り替え、テキストの出し分けを考えます。
ここでは例として、UIの選択状況によって表示が変わるフォームを扱います。フォームは「ロール」と「ポリシー」を選択する2つのフィールドを持っており、状態に応じて表示が切り替わります。
export const RoleForm = () => {
const {
control,
formState: { errors },
handleSubmit,
watch,
} = useForm<PostSchema>({
resolver: zodResolver(postSchema),
defaultValues: {
role: undefined,
policy: undefined,
},
});
const formState = new RoleFormState(watch());
// ^^^^^ フォームの状態と表示分岐を責務とするクラス (後述します)
const radioGroupRoleOptions = {
[Role.USER]: "一般ユーザー",
[Role.ADMIN]: "管理者ユーザー",
[Role.ROBOT]: "スクリプト",
} as const satisfies typeof roleOptions;
const submitHandler = handleSubmit((data) => {
console.log(data);
});
return (
<form
onSubmit={submitHandler}
className="flex flex-col gap-y-4 items-start"
>
/** ロールを選択するフィールド **/
<Controller
control={control}
name="role"
render={({ field }) => (
<RadioGroup
{...field}
onValueChange={(value) => {
const label = getOptionLabel(radioGroupRoleOptions, value);
if (!label) {
console.log("unexpected value:", value);
}
console.log(label);
}}
>
{Object.entries(roleOptions).map(([value, label]) => (
<div key={value} className="flex items-center space-x-2">
<RadioGroupItem
key={value}
value={value}
onClick={field.onChange}
/>
<Label
htmlFor={value}
className={cx(value === Role.ADMIN && "text-red-500")}
>
{label}
</Label>
</div>
))}
</RadioGroup>
)}
/>
{errors.role && <div>{errors.role.message}</div>}
/** ① ポリシーを選択するフィールド (ロールが管理者の場合にのみ表示) **/
{formState.shouldSelectPolicy && (
<Controller
control={control}
name="policy"
render={({ field }) => (
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="ポリシーを選択してください" />
</SelectTrigger>
<SelectContent>
{Object.entries(policyOptions).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
)}
{errors.policy && <div>{errors.policy.message}</div>}
/** ② UIの選択状況に応じて、表示の有無とテキストが切り替わる **/
{formState.description && formState.description}
<button type="submit">ロールを作成</button>
</form>
);
};
上記の例では二箇所 (①、②) の表示条件があり、それを扱う RoleFormState
クラスでロジックを管理しています。コンポーネント (特にJSX) から表示分岐に関するコードをごっそり減らせるため、読みやすいコードになっていると思います。
拘りポイントとして、クラスを使うことで formState.shoudlSelectPolicy
の様に自然言語として読みやすいコードが書ける様にしました。
import { Role } from "@prisma/client";
import { PostSchema } from "../validation";
class RoleFormState {
values: PostSchema;
shouldSelectPolicy: boolean;
description: string | undefined;
constructor(formValues: PostSchema) {
this.values = formValues;
this.shouldSelectPolicy = this.getShouldSelectPolicy();
this.description = this.getDescription();
}
private getShouldSelectPolicy = () => {
return this.values.role === Role.ADMIN;
};
private getDescription = () => {
switch (this.values.role) {
case "ADMIN": {
return "管理者ロールはポリシーを選択できます。";
}
case "USER": {
return "一般ユーザーのポリシーは読み取り専用になります。";
}
case "ROBOT": {
return undefined;
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _check: never = this.values.role;
}
}
};
}
おわりに
今回はフロントエンド開発で欠かせない、表示周りのコーディングについて考えました。こういった責務の分割は、コード量が膨らまなければ必要ないかもしれませんが、後から直したいときには手遅れになっていることもあると思います。プロジェクトの開始時点で足並みを揃えられる様に、何かしらのルールを決めておくと良いのかもしれません。
Discussion