【React Native】react-hook-formでフォーム作成

## react-hook-formとは
- **状態管理やバリデーションの実装がシンプル：**
フォームの状態管理やバリデーションを自分で管理する必要がなく、
難しいコードを書かなくても、フォームの入力値やエラー管理が行えます。
バリデーション（入力チェック）も簡単な設定だけで実現できます。
- **高速で軽い：**
大きなフォームでも、入力した部分だけ更新されるので、
アプリが重くなりにくい。
- **TypeScript対応：**
入力値の型（データの種類）をしっかり管理できるので、
型安全にフォームを扱える。
- **他のライブラリとの親和性**
React Native Paperなど、
他のデザインライブラリとも一緒に使える。

https://react-hook-form.com/

## 記事のきっかけ
react-hook-formの記事は多数ありますが、
React Nativeでの実装例がある記事を見かけなかったのと、
職業訓練学校の卒業制作でReact Nativeのアプリ作成をした際、
react-hook-formでフォーム作成したので、
記事作成を行おうと思った次第です。
（公式ドキュメントもReact向けのサンプルコードがほとんどのため）

:::note info
- 本記事ではUIコンポーネントでreact-native-paperを使っております
（卒業制作で利用していたので）
- react-native-paperの詳細については、割愛させていただきます
（時間があれば別記事で書きたいと思います）
:::

### フォーム作成準備
１．パッケージインストール
```
npm install react-hook-form
```

### 実装例
Reactでは主にRegisterを利用するケースが多いと思いますが、
React Nativeでは、主にControllerを利用します。
```tsx:hook_form_test.tsx
import { router } from "expo-router"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { Alert, StyleSheet, View } from "react-native"
import { Button, HelperText, Text, TextInput } from "react-native-paper"

// フォームデータの型定義（TypeScriptの型安全）
type FormData = {
    email: string;
    password: string;
    confirmPassword: string;
}

/**
 * HookFormTestコンポーネント
 * 
 * このコンポーネントは、React Hook Formを使用してユーザー登録フォームを提供します。
 * フォームには、メールアドレス、パスワード、パスワード確認の3つのフィールドが含まれています。
 * - メールアドレス：必須、メールアドレス形式である必要があります。
 * - パスワード：必須、8文字以上で英大文字、英小文字、数字を含む必要があります。
 * - パスワード確認：必須、パスワードと一致する必要があります。
 * 
 * フォーム送信時には、入力データがコンソールに出力され、
 * `Alert`で送信成功メッセージが表示されます。
 * また、`reset`関数でフォームがリセットされます。
 * 
 * @returns ユーザー登録フォームを含むReact要素
 */
const HookFormTest = () => {
    // パスワードのマスク表示
    // 初期表示時ではマスク表示
    const [secureTextEntry, setSecureTextEntry] = useState(true)
    const [confirmSecureTextEntry, setConfirmSecureTextEntry] = useState(true)

    // React-Hook-Form初期設定
    const {
        control,   // 各入力値（TextInputなど）とフォームの状態を繋ぐ
        handleSubmit,  // フォーム送信時の処理をまとめてくれる。バリデーションも自動で実施
        watch,  // フォームの入力値をリアルタイムで監視。（例：パスワード確認欄でパスワードの入力値と一致しているかチェックしたい時に使います。）
        formState: { errors },  // 入力ミスやバリデーションエラーの情報が格納されている。どの入力欄でどんなエラーが起きているかを簡単に取得可能
        reset  // フォームの入力値を初期状態に戻す関数。送信後やリセットボタンで使います。
    } = useForm<FormData>({  // フォーム全体を管理するための関数。フォームで使うデータ型を指定（TypeScriptの型安全のため）
        defaultValues: {  // フォームの初期値を設定
            email: '',
            password: '',
            confirmPassword: ''
        }
    })

    // confirmPasswordのバリデーションのためにpasswordフィールドを監視
    const password = watch('password')

    /**
     * フォーム送信時の処理
     * フォームに入力された情報をconsoleに出力し、
     * `Alert`で送信成功メッセージを表示します。
     * また、`reset`関数でフォームを初期状態にリセットします。
     * 
     * @param data - フォームに送信されたデータ
     */
    const onSubmit = (data: FormData) => {
        console.log('フォーム送信データ：', data)
        // APIなどで呼び出してユーザー情報を登録
        Alert.alert('送信成功しました。')
        // 送信後にフォームの入力値をリセット
        reset()
    }

    return (
        <View style={styles.container}>
            <Text style={styles.title}>ユーザー登録</Text>
            {/* React NativeではControllerを利用する */}
            <Controller
                control={control}  // controlは「フォーム全体の状態管理」を担うオブジェクト。Controllerコンポーネントに渡すことで、フォームの入力値やエラーを管理できる。
                name='email'
                // バリデーションのルール設定（必須と正規表現）
                rules={{
                    required: 'メールアドレスは必須項目です',
                    pattern: {
                        value: /\S+@\S+\.\S+/,
                        message: 'メールアドレスの形式で入力してください'
                    }
                }}
                // 入力欄をどのように表示するかを定義する
                // field：フォームの入力欄（例：TextInput）と
                // react-hook-formの状態管理をつなぐための情報や関数がまとまったオブジェクト
                render={({ field: { onChange, onBlur, value } }) => (
                    <View style={styles.inputContainer}>
                        <TextInput
                            label='email'
                            mode='outlined'
                            keyboardType="email-address"
                            value={value}
                            onChangeText={onChange}
                            onBlur={onBlur}
                            error={!!errors.email}
                            autoCapitalize="none"
                            left={<TextInput.Icon icon="email" />}
                        />
                        {errors.email && (
                            <HelperText type="error">{errors.email.message}</HelperText>
                        )}
                    </View>
                )}
            />
            <Controller
                control={control}
                name="password"
                rules={{
                    required: 'パスワードは必須です',
                    minLength: {
                        value: 8,
                        message: 'パスワードは８文字以上入力してください'
                    },
                    pattern: {
                        value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/,
                        message: 'パスワードは英大文字、英小文字、数字を含む必要があります'
                    }
                }}
                render={({ field: { onChange, onBlur, value } }) => (
                    <View style={styles.inputContainer}>
                        <TextInput
                            label="password"
                            mode="outlined"
                            secureTextEntry={secureTextEntry}
                            value={value}
                            onChangeText={onChange}
                            onBlur={onBlur}
                            error={!!errors.password}
                            left={<TextInput.Icon icon='lock' />}
                            right={
                                <TextInput.Icon
                                    icon={secureTextEntry ? 'eye-off' : 'eye'}
                                    onPress={() => setSecureTextEntry(!secureTextEntry)}
                                />
                            }
                        />
                        {errors.password && (
                            <HelperText type="error">{errors.password.message}</HelperText>
                        )}
                    </View>
                )}
            />
            <Controller
                control={control}
                name="confirmPassword"
                rules={{
                    required: 'パスワード（確認）は必須です',
                    validate: (value) => value === password || 'パスワードが一致してません'
                }}
                render={({ field: { onChange, onBlur, value } }) => (
                    <View style={styles.inputContainer}>
                        <TextInput
                            label="password(confirm)"
                            mode="outlined"
                            secureTextEntry={confirmSecureTextEntry}
                            value={value}
                            onChangeText={onChange}
                            onBlur={onBlur}
                            error={!!errors.confirmPassword}
                            left={<TextInput.Icon icon='lock' />}
                            right={
                                <TextInput.Icon
                                    icon={confirmSecureTextEntry ? 'eye-off' : 'eye'}
                                    onPress={() => setConfirmSecureTextEntry(!confirmSecureTextEntry)}
                                />
                            }
                        />
                        {errors.confirmPassword && (
                            <HelperText type="error">{errors.confirmPassword.message}</HelperText>
                        )}
                    </View>
                )}
            />
            <View style={styles.buttonContainer}>
                <Button
                    mode='contained'
                    // handleSubmitでバリデーションチェックを行い、
                    // バリデーションOKならonSubmit関数が実行される。
                    // NGの場合はrulesで定義したメッセージが画面に表示されます。
                    onPress={handleSubmit(onSubmit)}
                    style={styles.button}
                >
                    送信
                </Button>
                <Button
                    mode='outlined'
                    onPress={() => router.back()}
                    style={styles.button}
                >
                    戻る
                </Button>
            </View>
        </View>
    )
}

const styles = StyleSheet.create({
    container: {
        flex: 1,
        paddingVertical: 48,
        paddingHorizontal: 16

    },
    title: {
        fontSize: 20,
        fontWeight: 'bold',
        marginBottom: 16
    },
    inputContainer: {
        marginBottom: 8
    },
    buttonContainer: {
        marginTop: 16
    },
    button: {
        marginBottom: 16,
        padding: 4
    }
})

export default HookFormTest
```

### 画面起動（Androidエミュレータ）
#### 初期表示
![スクリーンショット 2025-04-21 23.45.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3983478/167ad39c-0e63-4a2b-af12-82c6fe74958c.png)
#### エラー発生時
![スクリーンショット 2025-04-21 23.47.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3983478/fe57732c-3f35-4442-8f62-57426c7d2c89.png)
#### 送信成功時
![スクリーンショット 2025-04-21 23.49.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3983478/021dfd62-f804-495c-baa3-7c2084126027.png)
#### 送信後（リセット）
![スクリーンショット 2025-04-21 23.52.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3983478/78047549-56f3-4a33-a56d-b195a134a784.png)