今回はクリックで編集状態を切り替える方法をご紹介します。使用技術はReact, Shadcn/ui, React-hook-form, Zodになります。既往歴をクリックすると詳細ダイアログが表示され、そのダイアログの編集したいfieldをクリックすると編集可能となります。


Contents
Dialog
Shadcnのdialogを使います。まずDocumentを確認してみましょう。

ダイアログを開くためのボタンなどをDialogTriggerで囲えばいいみたいです。今回はtableのrowをクリックするとDialogが表示されるように実装しています。
<Dialog>
<DialogTrigger asChild>
<TableRow>
{/* ここにコンテンツを入力してください */}
</TableRow>
</DialogTrigger>
</Dialog>
以下のコードは既往歴(PMH)を表示するtableの、各行のcomponentになります。recordはダイアログで表示させるための個別の情報を保持しています(diseaseName: 疾患名, diagnosisDate: 診断日, primaryCareProvider: かかりつけ病院, notes, idなど)。TableRowをクリックすると、PMHRecordDialog componentが開くようになっています。今回なぜ論理積演算子でレンダリングしているかというと({ isDialogOpen && (...) }の部分)、開閉ボタン実装やform送信後にdialogを閉じるなど、開閉を自由に操作したかったからです。純粋に閉じるボタンの実装だけであれば、DialogContent component内で、DialogClose componentを使用すれば閉じるボタンを実装できます。
※PMH: Past Medical History (既往歴)の略です。
ちなみにTailwind CSSにて className="hidden lg:table-cell" とすることで、mobile deviceならそのcellを隠し、large deviceならそのcellを表示させることができます。
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { TableCell, TableRow } from "@/components/ui/table";
import { useState } from "react";
import { PMHRecordDialog } from "./pmh-record-dialog";
type PMHResponseType {
diseaseName: string; //診断名
diagnosisDate: string | null; //診断日
primaryCareProvider: string | null; //かかりつけ病院
notes: string | null; //メモ
id: string; //recordのid. DBは今回のプロジェクトではFirestoreを使用。
}
export const PMHTableRow = ({ record }: { record: PMHResponseType }) => {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleCloseDialog = () => {
setIsDialogOpen(false);
};
return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<TableRow>
<TableCell>{record.diseaseName}</TableCell>
<TableCell className="hidden lg:table-cell">
{record.diagnosisDate}
</TableCell>
<TableCell className="hidden lg:table-cell">
{record.primaryCareProvider}
</TableCell>
</TableRow>
</DialogTrigger>
{isDialogOpen && (
<PMHRecordDialog record={record} onClose={handleCloseDialog} />
)}
</Dialog>
);
};
DialogContent
DialogContent部分である、PMHRecordDialog componentを見ていきましょう。まず大まかなロジックとしてはtextObjectによってfieldの値と編集中かどうか、そしてこのDialogが一度でも変更されたかどうか(hasEdited)を管理しています。

Types
//Props type
interface PMHRecordDialogProps {
record: PMHResponseType;
onClose: () => void; // ダイアログを閉じる関数
}
//recordのfield名です。
type PMHFieldNames =
| "diseaseName"
| "diagnosisDate"
| "primaryCareProvider"
| "notes";
//useStateで管理するobjectです。それぞれのfield(diseaseName, diagnosisDate, primaryCareProvider, notes)の値と編集状態を管理し、一度でも編集が行われたかどうかをcheckします。
type TextObjectType = {
[key in PMHFieldNames]: {
value: string;
isEditing: boolean;
};
} & {
hasEdited: boolean;
};
textObjectの生成
recordの値からtextObjectのdefault値を生成します。こちらは適宜書き換えてください。
const initializeTextObject = ( record: PMHResponseType ): TextObjectType => {
const defaultValue = {
diseaseName: record.diseaseName,
diagnosisDate: record.diagnosisDate ?? "",
primaryCareProvider: record.primaryCareProvider ?? "",
notes: record.notes ?? "",
};
const mapped = Object.entries(defaultValue).map(([key, value]) => {
return [
key,
{
value: value,
isEditing: false,
},
];
});
const formattedEntries = Object.fromEntries(mapped);
const defaultTextState = {
...formattedEntries,
hasEdited: false,
};
return defaultTextState;
};
const defaultTextState = initializeTextObject(record);
/* defaultTextStateの例
{
diseaseName: {value: '統合失調症', isEditing: false},
diagnosisDate: {value: '2025-07-05', isEditing: false},
primaryCareProvider: {value: 'XX大学病院', isEditing: false},
notes: {value: 'オランザピンで使用中', isEditing: false},
hasEdited: false,
}
*/
useState, useEffect
useEffectでcomponent rendering時にtextObjectを初期化しています。unmount時にcleanup関数でtextObjectを変更する方法もあるかもしれませんが、このdialogの結果がsaveやcancelなどいくつかあったので、rendering時の処理としています。ここの記述は自信はありません。より良い方法があれば教えてください。
import { useGetUser } from "@/features/auth/hooks/use-get-user"; //userを取得する関数を適宜準備してください。
//Component関数内
const { user } = useGetUser();
const [textObject, setTextObject] = useState<TextObjectType>(defaultTextState);
useEffect(() => {
//componentがrenderされたときにtextObjectを初期化する。
//これがないと、dialogを閉じた後、再度開いた際に前回のtextObjectが保持されている。
setTextObject(defaultTextState);
}, []);
const handleClose //キャンセル時のロジックを適宜記述してください。
const handleSave //保存時のロジックを適宜記述してください。今回はこの関数をsubmit関数にします。
const handleDelete //Delete時のロジックを適宜記述してください。
React-hook-form, Zod
React hook form, Get Started
まずzodでform validationのためのschemaを作成しましょう。そしてschemaから対応した型を生成します。
import { z } from "zod";
//Component関数内
const pmhSchema = z.object({
diseaseName: z
.string()
.min(1, "Disease name is required")
.max(100, "Disease name must be less than 100 characters"),
diagnosisDate: z
.string() //簡略化のために文字列としてますが、多分date型の方が適切です。
.max(100, "Diagnosis date must be less than 100 characters"),
primaryCareProvider: z
.string()
.max(100, "Primary care provider name must be less than 100 characters"),
notes: z.string().max(1024, "Notes must be less than 1024 characters"),
});
type PMHSchemaType = z.infer<typeof pmhSchema>
このschemaを元にreact-hook-formのform objectを生成します。
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
//Component関数内
const defaultValue =
{
diseaseName: record.diseaseName,
diagnosisDate: record.diagnosisDate ?? "",
primaryCareProvider: record.primaryCareProvider ?? "",
notes: record.notes ?? "",
};
const form = useForm<PMHSchemaType>({
resolver: zodResolver(pmhSchema), //このresolverで、formの構造を決定します。
defaultValues: defaultValue,
mode: "onChange", //dialogのfieldを変更するたびにvalidationが走ります。defaultは"onSubmit"で、こちらはsubmit時にvalidationが走ります。
});
こちらのform objectには以下のようなpropertyがあります。
- register
- 引数にnameを受け取りobjectとしてname, onChange, onBlur, refなどを返却する関数。まさしくinput要素をRHFのformに登録する役割。
- control
- registerと同じようにinputをRHFに登録する。Shadcn/uiのForm componentを使う場合は、registerではなくこちらのcontrolを使ってinputとRHFを紐付けます。
- handleSubmit
- Submit関数のラッパー関数
- 入力値を
resolver(例:Zod)でバリデーション。submit関数はonSubmitとして説明します。 - エラーがなければ →
onSubmit(data)を呼ぶ - エラーがあれば →
formState.errorsにerror messageを入れて再レンダリング(onSubmitは呼ばれない)
Form
Shadcn/uiのForm componentを使用します。
React Hook Form – shadcn/ui
<Form {...form}>で、RHFのform objectを分割代入します。このForm componentで、<form onSubmit={form.handleSubmit(onSubmit)}>TSXのform (HTMLのform相当)をラップします。
次にFormFieldでinputをRHFに紐付けながらrenderingします。一つのinputにつき一つのFormFieldを使います。
//Shadcn/uiのForm使用例。usernameを入力するためのformです。
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
//Component関数内
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control} //form objectのcontrolを渡してRHFと紐付けます。
name="username" //zodのSchemaのpropertyにある名前を入力します。
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name.
</FormDescription>
<FormMessage /> //Errorが生じた場合、FormMessageで表示されます。
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
TSX
以上を踏まえてTSX部分を実装していきます。
FormField部分
こちらはFormFieldの各々のfieldをrenderingするcomponentです。formNameでfieldを特定しています。formNameはこの例ではdiseaseNameやdiagnosisDateなどです。
まずformNameが編集中でない時、textObjectのformNameのvalueがpタグで表示されます。クリックされた時にhandleEditMode関数で編集状態を切り替えます
{ textObject[formName].isEditing ? (
{/* ここに編集中に表示するcomponentを記述 */}
) : (
<p onClick={() => handleEditMode()}>
{textObject[formName].value ? textObject[formName].value : <br />}
</p>
)}
次にFormControl部分です。onBlurにhandleEditMode関数を渡すことでFocusが外れた時に、編集状態を切り替えます。またInputもしくはTextAreaにautoFocusを設定することで、編集状態が切り替わった時に自動でFocusされるようにします。
<FormControl>
{type === "text" ? (
<Input
{...field}
onBlur={() => handleEditMode()}
autoFocus
onChange={(e) => handleValueChange(e.target.value)}
value={textObject[formName].value}
/>
) : (
<Textarea
{...field}
onBlur={() => handleEditMode()}
autoFocus
onChange={(e) => handleValueChange(e.target.value)}
value={textObject[formName].value}
/>
)}
</FormControl>
DialogFormField componentの全体です。
import { FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
import { Input } from "@ui/input";
import { Textarea } from "@ui/textarea";
type DialogFormFieldProps = {
form: UseFormReturn<PMHSchemaType>;
formName: keyof PMHSchemaType;
textObject: TextObjectType;
setTextObject: React.Dispatch<React.SetStateAction<TextObjectType>>;
type?: "text" | "textarea"; //もしdate型も扱う場合は"date"を追加してください
}
// TextObjectTypeは上のTypes参照。
export const DialogFormField = ({
form,
formName,
textObject,
setTextObject,
type = "text",
}: DialogFormFieldProps) => {
const handleEditMode = () => {
setTextObject((prev: TextObjectType) => ({
...prev,
[formName]: {
...prev[formName],
isEditing: !prev[formName].isEditing,
},
}));
};
const handleValueChange = (formValue: string) => {
setTextObject((prev: TextObjectType) => ({
...prev,
[formName]: {
...prev[formName],
value: formValue,
},
hasEdited: true, //この部分で一度でも編集を行なったかどうかcheck。
}));
form.setValue(formName, formValue);
};
return (
<FormField
control={form.control}
name={formName}
render={({ field }) => (
<FormItem>
{textObject[formName].isEditing ? (
<FormControl>
{type === "text" ? (
<Input
{...field}
onBlur={() => handleEditMode()}
autoFocus
onChange={(e) => handleValueChange(e.target.value)}
value={textObject[formName].value}
/>
) : (
<Textarea
{...field}
onBlur={() => handleEditMode()}
autoFocus
onChange={(e) => handleValueChange(e.target.value)}
value={textObject[formName].value}
/>
)}
</FormControl>
) : (
<p onClick={() => handleEditMode()}>
{textObject[formName].value ? textObject[formName].value : <br />}
</p>
)}
<FormMessage />
</FormItem>
)}
/>
);
};
DialogContent部分
上記のDialogFormFieldを用いてDialogContent部分記述していきます。hasEditedがfalseの間はsave buttonをdisableに設定しています。
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Button } from "@/components/ui/button";
import { DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Form } from "@/components/ui/form";
import { DialogFormField } from "./dialog-form-field";
//Component内
return (
<DialogContent showCloseButton={false}> {/* close buttonを隠しています。defaultでは右上に表示されます。 */}
{/* DialogContent内にはaccessibilityのためにDialogTitleが必要です。radix-uiのVisuallyHiddenでラップすることで、DialogTitleを隠しています。 */}
<VisuallyHidden>
<DialogTitle>Past Medical History Record</DialogTitle>
</VisuallyHidden>
<Form {...form}>
<form className="space-y-4" onSubmit={form.handleSubmit(handleSave)}>
<div>
<h3>Disease Name</h3>
<DialogFormField
form={form}
formName="diseaseName"
textObject={textObject}
setTextObject={setTextObject}
/>
</div>
<div>
<h3>Diagnosis Date</h3>
<DialogFormField
form={form}
formName="diagnosisDate"
textObject={textObject}
setTextObject={setTextObject}
/>
</div>
<div>
<h3>Primary Care Provider</h3>
<DialogFormField
form={form}
formName="primaryCareProvider"
textObject={textObject}
setTextObject={setTextObject}
/>
</div>
<div>
<h3>Notes</h3>
<DialogFormField
form={form}
formName="notes"
textObject={textObject}
setTextObject={setTextObject}
type="textarea"
/>
</div>
<div className="flex items-center justify-between">
<Button
className="w-30 bg-blue-500 hover:bg-blue-400"
disabled={!textObject.hasEdited}
>
Save
</Button>
<Button
className="w-30 bg-yellow-400 hover:bg-yellow-300 text-black"
type="button"
onClick={handleClose}
>
Close
</Button>
</div>
</form>
</Form>
<Button type="button" onClick={handleDelete} className="w-full">
Delete
</Button>
</DialogContent>
);