Rork に「ログイン画面を作って」と頼むと、数十秒で動く画面が返ってきます。私自身、個人開発で運用している壁紙アプリの設定画面やフィードバックフォームを Rork で下書きさせることがありますが、生成直後のフォームをそのまま App Store に出したことは一度もありません。理由は単純で、生成コードは「画面として成立している」ものの「指で触れる道具」にはなっていないからです。
具体的には、入力するたびに画面全体がちらつき、iPhone の実機ではキーボードが送信ボタンを覆い隠し、通信が遅いと送信ボタンを2回押せてしまいます。これらは Rork が悪いというより、AI が下地を作り、人が仕上げるという役割分担のうち「仕上げ」の部分です。ここでは、生成されたフォームを react-hook-form と zod で実戦仕様に組み直す手順を、判断の根拠とともに残します。
Rork 生成フォームに共通する3つの崩れ方
生成直後のコードは、たいてい次のような形をしています。フィールドごとに useState を置き、ボタンを押した瞬間にまとめて検証する素朴な作りです。
// Rork が出力しがちな素朴なフォーム(問題を抱えている)
function LoginScreen() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const onSubmit = async () => {
if (!email.includes("@")) {
setError("メールアドレスの形式が正しくありません");
return;
}
if (password.length < 8) {
setError("パスワードは8文字以上です");
return;
}
await signIn(email, password); // 連打すると複数回走る
};
return (
<View style={{ padding: 16 }}>
<TextInput value={email} onChangeText={setEmail} placeholder="メール" />
<TextInput value={password} onChangeText={setPassword} secureTextEntry placeholder="パスワード" />
{error ? <Text style={{ color: "red" }}>{error}</Text> : null}
<Button title="ログイン" onPress={onSubmit} />
</View>
);
}
この実装には、実機で初めて表に出る問題が3つ潜んでいます。
入力1文字ごとに画面全体が再描画される
useState の更新は、その state を持つコンポーネント(ここでは LoginScreen 全体)を再レンダリングします。フィールドが2つなら気づきませんが、5つ6つと増え、検証ロジックや派生表示が乗ってくると、文字を打つたびに重い再描画が走り、入力が一拍遅れて感じられます。私の経験では、設定画面のように項目が多いフォームでこの遅延が顕著になります。
キーボードが送信ボタンを隠す
View をそのまま並べただけの画面は、ソフトウェアキーボードが出ると下半分が覆われます。とくに iPhone のパスワード欄にフォーカスした瞬間、肝心の送信ボタンが見えなくなり、ユーザーはキーボードを閉じてからでないとボタンを押せません。この一手間が、入力完了直前の離脱を生みます。
通信中にボタンを連打できてしまう
onSubmit は非同期ですが、実行中であることをどこにも記録していません。通信が遅い回線では、ユーザーは「反応がない」と感じてもう一度押します。結果として同じ送信が二重に走り、アカウント作成フォームなら重複登録、課金導線なら二重課金の温床になります。
なぜ react-hook-form と zod を選ぶのか
選択肢としては、Formik や独自フックという道もありますが、私は react-hook-form と zod の組み合わせを推奨します。判断の根拠は3つあります。
第一に、react-hook-form は入力値を「非制御」に近い形で内部に保持し、画面全体の再描画を避けられます。第二に、zod でスキーマを1か所に書けば、型(TypeScript)とバリデーションが同じ定義から導かれ、ずれが起きません。第三に、どちらも依存が軽く、Rork が吐く Expo プロジェクトにそのまま乗ります。
Expo プロジェクトには次のように追加します。
# Rork からエクスポートしたプロジェクトのルートで
npx expo install react-hook-form
npx expo install zod @hookform/resolvers
@hookform/resolvers は zod スキーマを react-hook-form に橋渡しするアダプタです。これを忘れると検証が一切走らないので、最初に入れておきます。
zod でスキーマを1か所に定義する
検証ルールはコンポーネントの中に散らかさず、スキーマとして独立させます。こうすると、後からルールを足すときに1ファイルだけ見れば済みます。
// schemas/login.ts
import { z } from "zod";
export const loginSchema = z.object({
email: z
.string()
.min(1, "メールアドレスを入力してください")
.email("メールアドレスの形式が正しくありません"),
password: z
.string()
.min(8, "パスワードは8文字以上で入力してください"),
});
// 入力フォームの型をスキーマから導出する(手書きの型と二重管理しない)
export type LoginForm = z.infer<typeof loginSchema>;
ポイントは z.infer で型をスキーマから生成していることです。フィールドを1つ増やすときも、スキーマに1行足すだけで型と検証の両方が更新されます。手書きの interface を別に持つと、いつか必ずずれます。
TextInput を1つずつ包む再利用コンポーネント
react-hook-form は Web の <input> を前提とした API を持つため、React Native の TextInput には Controller を介して接続します。毎回 Controller を書くと冗長なので、エラー表示まで内蔵した再利用コンポーネントにまとめます。
// components/ControlledInput.tsx
import { Control, Controller, FieldValues, Path } from "react-hook-form";
import { Text, TextInput, TextInputProps, View } from "react-native";
import { forwardRef } from "react";
type Props<T extends FieldValues> = {
control: Control<T>;
name: Path<T>;
label: string;
} & TextInputProps;
// ref を転送して「次へ」でのフォーカス移動を可能にする
function ControlledInputInner<T extends FieldValues>(
{ control, name, label, ...rest }: Props<T>,
ref: React.Ref<TextInput>
) {
return (
<Controller
control={control}
name={name}
render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
<View style={{ marginBottom: 16 }}>
<Text style={{ marginBottom: 6, fontWeight: "600" }}>{label}</Text>
<TextInput
ref={ref}
value={value}
onChangeText={onChange}
onBlur={onBlur}
style={{
borderWidth: 1,
borderColor: error ? "#e5484d" : "#d0d0d0",
borderRadius: 8,
padding: 12,
}}
{...rest}
/>
{/* エラーは入力欄の直下に出す。画面下にまとめると原因の欄が分からない */}
{error ? (
<Text style={{ color: "#e5484d", marginTop: 4, fontSize: 13 }}>
{error.message}
</Text>
) : null}
</View>
)}
/>
);
}
export const ControlledInput = forwardRef(ControlledInputInner) as <T extends FieldValues>(
props: Props<T> & { ref?: React.Ref<TextInput> }
) => React.ReactElement;
ここで forwardRef を使っているのは、次の節で扱う「メール欄でリターンキーを押したらパスワード欄へ移る」というフォーカス移動を実現するためです。エラーメッセージを入力欄の直下に置いているのも意図的で、画面下にまとめて表示する設計だと、どの欄が間違っているのか指で追えません。
ログイン画面を本番品質に組み直す
部品がそろったので、画面を組み立てます。再描画の抑制、キーボード回避、フォーカス移動、二重送信防止を一度に入れます。
// screens/LoginScreen.tsx
import { useRef } from "react";
import {
KeyboardAvoidingView,
Platform,
ScrollView,
TextInput,
TouchableOpacity,
Text,
} from "react-native";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema, type LoginForm } from "../schemas/login";
import { ControlledInput } from "../components/ControlledInput";
export function LoginScreen() {
const passwordRef = useRef<TextInput>(null);
const {
control,
handleSubmit,
setError,
formState: { isSubmitting },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
mode: "onTouched", // 一度触れた欄だけ、離れた時点で検証する
});
const onSubmit = async (values: LoginForm) => {
try {
await signIn(values.email, values.password);
} catch (e) {
// サーバー側のエラーを該当フィールドへ差し戻す
setError("password", {
type: "server",
message: "メールアドレスまたはパスワードが違います",
});
}
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{ padding: 16 }}
keyboardShouldPersistTaps="handled"
>
<ControlledInput
control={control}
name="email"
label="メールアドレス"
placeholder="you@example.com"
autoCapitalize="none"
keyboardType="email-address"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
/>
<ControlledInput
ref={passwordRef}
control={control}
name="password"
label="パスワード"
secureTextEntry
returnKeyType="done"
onSubmitEditing={handleSubmit(onSubmit)}
/>
<TouchableOpacity
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting} // 送信中はボタンを無効化して連打を封じる
style={{
backgroundColor: isSubmitting ? "#9aa0a6" : "#1a73e8",
borderRadius: 8,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: "#fff", fontWeight: "700" }}>
{isSubmitting ? "送信中…" : "ログイン"}
</Text>
</TouchableOpacity>
</ScrollView>
</KeyboardAvoidingView>
);
}
このコードで、先ほどの3つの崩れ方がすべて解消されます。入力値は react-hook-form が内部で保持するため、文字を打っても画面全体は再描画されません。isSubmitting でボタンを無効化しているので、通信中の連打は届きません。KeyboardAvoidingView と ScrollView の組み合わせで、キーボードが出ても送信ボタンまでスクロールできます。
mode: "onTouched" を選ぶ理由
mode を onChange にすると、1文字打つたびに検証が走り、入力途中で「メールアドレスの形式が正しくありません」と赤字が点滅します。これは急かされている印象を与えます。逆に onSubmit だけだと、送信して初めてエラーに気づきます。onTouched(一度フォーカスして離れた欄だけ検証)が、せかさず、しかし手戻りも少ない中庸で、私はこの設定を既定にしています。
KeyboardAvoidingView の iOS と Android 差を吸収する
KeyboardAvoidingView は、iOS と Android で素直に同じ挙動をしてくれません。ここを理解せずにコピーすると、片方のOSだけで送信ボタンが隠れたままになります。
| 項目 | iOS | Android |
| 推奨 behavior | padding | undefined(OS が自動調整) |
| ヘッダーがある場合 | keyboardVerticalOffset にヘッダー高さを指定 | 多くの場合は不要 |
| android:windowSoftInputMode | 影響なし | adjustResize が前提 |
Android では app.json の設定が効いてきます。Rork が出力する app.json に softInputMode が入っていない場合は、次を追記します。
{
"expo": {
"android": {
"softwareKeyboardLayoutMode": "resize"
}
}
}
ヘッダー(ナビゲーションバー)がある画面では、iOS で keyboardVerticalOffset を渡さないと、キーボードとヘッダーの高さ分だけ画面が押し上がりすぎたり足りなかったりします。@react-navigation/elements の useHeaderHeight() で実測した高さを渡すのが確実です。このあたりの細かな調整はキーボードが入力欄に被るときの対処でも個別に扱っています。
サーバーエラーを「正しい場所」に返す
クライアント側の検証を通っても、サーバーが弾くケースは必ずあります。メールアドレスが既に使われている、パスワードが流出リストに含まれている、といったエラーです。これらを画面上部の赤帯にまとめて出すのは、原因の欄から視線が離れるため避けたいところです。
react-hook-form の setError を使うと、サーバーのエラーを特定のフィールドへ差し戻せます。先ほどの onSubmit で setError("password", ...) としていたのがそれです。サーバーがフィールド単位のエラーを返す API なら、次のように機械的に対応づけられます。
// サーバーが { field: "email", message: "..." } の配列を返す場合
try {
await signUp(values);
} catch (e) {
const fieldErrors = parseServerErrors(e); // [{ field, message }]
for (const fe of fieldErrors) {
setError(fe.field as keyof LoginForm, {
type: "server",
message: fe.message,
});
}
}
こうすると、サーバー由来のエラーもクライアント検証と同じ見た目で、同じ欄の直下に出ます。ユーザーから見れば、どこで弾かれても直すべき場所は一目瞭然です。生成コードを本番に通す前のリファクタリングの考え方は、Rork 生成コードを本番品質へリファクタリングするにも通じます。
どこまで Rork に任せ、どこから手で直すか
私が個人開発で続けている運用の感覚として、フォームは「Rork に骨組みを出させて、入力体験は人が仕上げる」のが最も速い、と考えています。配置・色・ラベルといった見た目は生成に任せて構いません。けれど、再描画・キーボード・二重送信・サーバーエラーの差し戻しという4点は、実機で指を動かして初めて分かる領域で、ここはコードを読みながら自分で手を入れる価値があります。
次の一歩として、いまお使いのアプリで一番入力項目が多いフォーム画面を1つ選び、この ControlledInput に置き換えてみてください。1画面を通すと、残りの画面は同じ型紙で一気に直せます。日本語入力で文字が落ちる症状に遭遇したら、TextInput で文字が落ちる・カーソルが飛ぶ問題も併せて確認すると、入力周りの不安がひと通り片付きます。お読みいただきありがとうございました。