フォームのstateとDBモデルを分けて考えると扱いやすい話
こんにちは 人材育成室 育成メンバーチームで 研修中の はすと です。
研修で社内ツールの開発に携わっているのですが、フォームの実装で少し詰まったことがありました。DBのモデル型をそのままフォームのstateの型として使用していたら、レビューで「フォームのstateとDBのモデルは分けた方がいい」ということを教わりました。
本記事では、フォームのstateとDBモデルを分けるべき理由を、研修中に実際にハマったケースと一緒にまとめます。
私と同じように「なぜ分ける必要があるの?」と感じている方の参考になればと思います。
先に結論
フォームのstateとDBモデルは分けた方が扱いやすい、というのが今回の結論です。
理由を一文で言うと、フォームは「ユーザーの入力プロセス」を表現するもので、DBモデルは「確定したデータ構造と制約」を表現するものだからです。目的が違うため、最適な形も違います。
一致させようとすると、その歪みはUIに跳ね返り、結果的にUXが犠牲になります。その具体例についてはこれから見ていきます。
よくあるフォームとDBモデルの例
ユーザー登録フォームを想定して、DBのモデル型がこう定義されているとします。
type User = {
name: string;
middleName?: string; // ミドルネーム(任意)
age: number;
birthday: Date;
};
これをそのままフォームのstateに使い、ネイティブなReact(useState)でフィールドごと素直に実装するとこうなります。DBモデルの各フィールドの型をそのまま useState の型に持っています。
function UserForm() {
const [name, setName] = useState<string>("");
const [middleName, setMiddleName] = useState<string | undefined>(undefined);
const [age, setAge] = useState<number>(0);
const [birthday, setBirthday] = useState<Date>(new Date());
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
value={middleName ?? ""}
onChange={(e) => setMiddleName(e.target.value)}
/>
<input
type="number"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
/>
<input
type="date"
value={birthday.toISOString().slice(0, 10)}
onChange={(e) => setBirthday(new Date(e.target.value))}
/>
</form>
);
}
一見、動きそうに見えまが、書いている途中ですでに ?? "" や Number()、toISOString() といった変換が顔を出してきます。ここから先に進むと違和感が徐々に出てきます。どこがおかしいのか、3つのケースで見ていきます。
ハマりがちなケース①: number 型のズレ
まずは一番シンプルな例から見ていきます。
<input type="number"> が返す値は、本当に number か?
HTMLの感覚だと、<input type="number"> に「25」と入力したら、value には数値の 25 が入ってくるはず、と考えると思います。
実際に確かめてみました。
<input
type="number"
onChange={(e) => console.log(typeof e.target.value, e.target.value)}
/>
ユーザーが「25」と入力すると、コンソールには string "25" と出ます。
type="number" にしても、HTMLの仕様上、input要素の value プロパティは常に文字列を返します。state を number で持ちたいなら、Number() などで自前で変換するしかありません。
つまり、ブラウザから返ってくる値は常に string の世界にいるわけです。
空にしたらどうなる?
もうひとつ引っかかるポイントがあります。ユーザーが一度「25」と入力して、全部消した瞬間です。stateには何が入るべきでしょうか。
DBモデルに合わせて age: number で持とうとすると、候補はこうなります。
0: 年齢0歳になってしまう。NaN: 空文字列をNumber()で変換すると、実際にはこれが入る。DBには保存できない。undefined: 「未入力」を表現はできるが、型はnumber | undefinedになる。
ここで気づくのは、undefined を許容した時点でもうDBのモデル型とは一致していないということです。DBは age: number(NOT NULL想定)、フォームは age: number | undefined ということで、すでに別物になっています。
一致させたいなら、UIを縛るしかない
どうしても型を一致させたいなら、空を許可しなければいい、という発想です。
<input type="number" required min="1" />
これでDBモデルと型は揃います。しかし、ちょっと考えてみてください。
- フォームを開いた瞬間、初期値は何にするのか
- 「一度入力したが、考え直すため一度消したい」が許されない
これは、本当に正しいUIなのでしょうか?
何が起きてしまっているのか
DBモデルに合わせようとすると、システムの都合がUIに跳ね返ってユーザー体験が悪くなる。これが「一致させようとするとハマる」の正体だと感じました。
素直に考えれば、フォームのstateはこうあるべきです。
type UserFormData = {
name: string;
age: string; // inputが返すのは常にstring、空も "" で表現できる
};
DBモデルとは別にしておいて、submit時に number へ変換してAPIに送る。これだけでUIの自由度が戻ってきます。
ハマりがちなケース②: string の空文字と undefined のズレ
次は、オプショナルな string フィールドの「空」を "" で表すか undefined で表すかという話です。
空を表すのは "" か undefined か
input要素が返す空は ""(空文字列)です。
一方、DB側では「未入力 = NULL(TypeScriptなら undefined や null)」として扱いたいケースがよくあります。オプショナルなミドルネーム、電話番号、備考欄など「入力してもしなくてもいい」項目です。先ほどの User 型でも middleName?: string になっていて、未入力は undefined で表現する想定です。
フォームstateをこのDB型に合わせると、middleName: string | undefined になります。
結局どこかで変換が必要になる
ここで input 側の仕様を思い出すと、<input type="text"> の value は string 固定です。ユーザーが空欄のままなら、手元に来るのは ""(空文字列)であって undefined ではありません。
つまり、フォームstateを string | undefined で持っていても、input から流れてくるのは ""(空文字列)です。「未入力 = undefined」を維持したいなら、submit時(あるいは onChange 時)に "" を undefined に変換する処理がどこかに必要になります。
// submit時にこういう変換が必要になる
const middleName = form.middleName === "" ? undefined : form.middleName;
逆に、stateを string | undefined のまま input に渡そうとすると、今度は input 側で困ります。
<input value={middleName} />
// middleName が undefined だと「uncontrolled → controlled」の警告が出る
これを避けるには value={middleName ?? ""} のような変換が必要です。
つまり、フォームの "" と DB の undefined は別物なので、どちらの形でstateを持っても変換からは逃げられない。
フォームはフォーム、DBはDBとして持つ
フォームstateは "" の世界で統一しておけばシンプルです。
type UserFormData = {
name: string;
middleName: string; // 空は ""
};
submit時に「"" なら undefined に変換してAPIに送る」。この一方向の変換だけで、フォーム側の都合とDB側の都合が両方きれいにまとまります。
ハマりがちなケース③: 日付の形式
3つ目は一番ギャップが大きい話です。ユーザーに入力させる形と、DBで持ちたい形が一致しないケースです。
日付入力の戻り値は結局 string
<input type="date"> を使うと、ブラウザがピッカーUIを出してくれるので、ユーザーはカレンダーから日付を選べます。
ただし、JavaScript側に流れてくる値は "2026-04-21" のような ISO 8601 形式の文字列です。ピッカーで選ばせていても、結局 input が返すのは string です。
DB側が持ちたい日付は色々
一方、DBで日付を保存する形には複数の選択肢があります。
Dateオブジェクト- Unix time(number)
- タイムゾーン付き ISO 8601 文字列(
"2026-04-21T00:00:00Z")
いずれも、input が返す "2026-04-21" とそのまま一致するとは限りません。
では、DBの形に合わせて入力させればいい?
ここで「DB型に合わせて入力させればいいのでは」と考えたくなります。でも、現実的にはほぼ無理です。
Dateオブジェクトを直接打たせるUIは存在しない- Unix time(
1745798400のような数字)を打たせる → カレンダーから逆算するのは現実的でない - タイムゾーン付き ISO 8601 を手で打たせる → タイポだらけになる
DBが ISO 8601 文字列で持っている場合は <input type="date"> の戻り値をそのまま流せそうにも見えますが、タイムゾーンの解釈周りに罠があり、結局変換ロジックはどこかで必要になります。
具体的な実装例
ここまでの話をまとめて、フォーム全体の実装を考えてみます。
方針
- フォームstateは「inputが返す形」に寄せる: string で持ち、空は
""で表現する - DBモデルは別に定義する: number / Date /
undefinedなど、DBに保存しやすい型にする - バリデーションは入力中、変換は送信時: 入力中はフォーム state の値で検証し、送信時に DBモデルの型へ変換する
ネイティブなReactでの実装例
実際のコードに落とすと、こんな感じになります。
import { useState, type FormEvent } from "react";
// フォーム側の型(inputの戻り値に合わせてstring中心)
type UserFormData = {
name: string;
middleName: string; // 空は ""
age: string;
birthday: string;
};
// DB側の型(再掲)
type User = {
name: string;
middleName?: string;
age: number;
birthday: Date;
};
// 変換関数: フォームの値をDBモデルへ
function toDbModel(form: UserFormData): User {
return {
name: form.name,
middleName: form.middleName === "" ? undefined : form.middleName,
age: Number(form.age),
birthday: new Date(form.birthday),
};
}
function UserForm() {
const [form, setForm] = useState<UserFormData>({
name: "",
middleName: "",
age: "",
birthday: "",
});
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// 入力値のバリデーション(必要な分だけ)
if (form.name === "") return;
if (!/^\d+$/.test(form.age)) return;
if (form.birthday === "") return;
const user = toDbModel(form);
await api.createUser(user);
};
return (
<form onSubmit={handleSubmit}>
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
/>
<input
value={form.middleName}
onChange={(e) => setForm({ ...form, middleName: e.target.value })}
/>
<input
type="number"
value={form.age}
onChange={(e) => setForm({ ...form, age: e.target.value })}
/>
<input
type="date"
value={form.birthday}
onChange={(e) => setForm({ ...form, birthday: e.target.value })}
/>
</form>
);
}
ポイントは、
- フォームstateは全部 string で揃える
- 初期値は空文字
""でOK(undefinedを使わなくていい) - submit時に
toDbModelで一括変換する - 変換関数1つに責務が集まるので、テストもしやすい
というあたりです。
フォーム state の持ち方について
サンプルコードでは useState<UserFormData>({...}) のように 1つのオブジェクトで持つ書き方をしてきました。一方で、フィールドごとに分ける書き方もあります。
// パターンA: フィールドごとに useState を持つ
const [name, setName] = useState("");
const [age, setAge] = useState("");
// パターンB: オブジェクトで1つの useState を持つ
const [form, setForm] = useState<UserFormData>({
name: "",
age: "",
});
フィールドごと(パターンA)
- メリット: コードが直感的、各stateが独立、型推論が単純
- デメリット: フィールドが増えると
useStateが増える、toDbModel(form)のような変換に渡すには毎回オブジェクトに集約する必要がある
オブジェクト(パターンB)
- メリット: 「フォーム全体」を1つの型・1つの値として扱える、変換関数やバリデーションに渡しやすい
- デメリット:
onChangeで毎回{ ...form, age: e.target.value }のスプレッドが必要
この記事では、フォーム全体を UserFormData 型で扱いたい・toDbModel(form) で一括変換したい、というところでパターンB(オブジェクト)を採用しています。
実務で中規模以上のフォームを扱うときは、React Hook Form や TanStack Form などのライブラリを使うのが今の主流かなと思います。なので、素のReactで管理するのは、小規模フォームか学習目的が中心になります。
まとめ
研修で「フォームのstateとDBモデルは分けた方がいい」と教わったとき、説明を聞いて「なるほど、確かに」とその場で納得はできました。しかし、頭で分かったつもりでも自分で確かめないと身にならない気がして、今回はいくつかのケースで実際に検証してみました。
検証して改めて感じたのは、入力プロセスとデータの保存形式は目的が違うから、型も違って当然という考え方です。この視点を持つと、それまで「なんかハマるな」と感じていたことが、型を揃えようとして無理をしていただけだったのだ、と腑に落ちました。
フレームワークや言語が変わっても応用が利きそうな考え方なので、これからフォームを実装するときは「stateとDBは別物」のところから設計を始めてみようと思います。
私と同じように、フォームのstateとDBモデルの扱いでモヤっとしたことがある方の参考になれば嬉しいです。






