🍺

Nextjs+ChakraUi+FirebaseAuth+react-hook-formで作るログイン画面(その2:コンポーネント編)

2024/05/13に公開

概要

今回は、react-hook-formのTSX(コンポーネント)側の実装を見ていきます。
その1:ロジック編
その2:コンポーネント編 🔗今ここ
その3:パスワードリセット編
その4:ソーシャルログイン編(作成中)

全体のコードと画面

ここをクリックしてコードを表示
// react, next
import { useRouter } from "next/router";
import NextLink from 'next/link';
// 3rd party
import {
    Box,
    HStack,
    Link,
    Stack,
} from '@chakra-ui/react';
import {
    Auth,
    getAuth,
    signInWithEmailAndPassword,
} from "firebase/auth";
import { FirebaseError } from 'firebase/app';
import { useForm, SubmitHandler } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
// ganymede
import { Layout } from "../../components/Layout";
import { AccountSubmitButton } from '../../components/atoms/Button';
import { FormCard } from '../../components/organisms/Card';
import { SocialAuth } from "../../components/organisms/SocialAuth";
import { ModalAlert } from "../../components/organisms/Modal";
import { FormPasswordInputControl, FormTextInputControl } from '../../components/organisms/Form';
import { firebaseApp } from '../../firebase/firebase';
import { useErrorHandler } from "../../hooks/useModalHandler";
import { signinSchema } from '../../validation/validationSchemas';


type InputFormData = {
    email: string;
    password: string;
};


const SignIn = () => {
    const router = useRouter();
    const { isOpen, onClose, errorMessage, errorHandler } = useErrorHandler();

    const formDefaultValues: InputFormData = {
        email: '',
        password: '',
    };
    const {
        register,
        handleSubmit,
        reset,
        formState: { errors },
    } = useForm({
        mode: 'all',
        defaultValues: formDefaultValues,
        resolver: yupResolver(signinSchema),
    });

    const onSubmit: SubmitHandler<InputFormData> = async (data) => {
        try {
            const auth: Auth = getAuth(firebaseApp);
            await signInWithEmailAndPassword(auth, data.email, data.password);
            router.push('/');
        } catch (e: unknown) {
            const error = e instanceof FirebaseError ? e : e as Error;
            errorHandler(error);
            reset({ email: '', password: '' });
        }
    };

    return (
        <Layout title="Sign In">
            <FormCard title="サインイン">
                <form onSubmit={handleSubmit(onSubmit)} data-testid="sign-in-form">
                    <Stack spacing={4}>
                        <FormTextInputControl
                            id="email"
                            isInvalid={errors.email ? true : false}
                            placeholder="email"
                            registers={register('email')}           // registerの引数はInputDataのkey
                            errorMessage={errors.email && errors.email.message}
                        >
                            Email
                        </FormTextInputControl>
                        <Box>
                            <FormPasswordInputControl
                                id="password"
                                isInvalid={errors.password ? true : false}
                                placeholder="password"
                                registers={register('password')}    // registerの引数はInputDataのkey
                                errorMessage={errors.password && errors.password.message}
                            >
                                パスワード
                            </FormPasswordInputControl>
                            <Box mt={2}>
                                <Link as ={NextLink} href='/account/pw-reset-mail' color={'blue.400'}>
                                    パスワードをお忘れですか?
                                </Link>
                            </Box>
                        </Box>
                        <HStack alignItems="center" justifyContent="end">
                            <AccountSubmitButton>Sign In</AccountSubmitButton>
                        </HStack>
                        <ModalAlert
                            isOpen={isOpen}
                            modalTitle="Error"
                            onClose={onClose}
                        >
                            {errorMessage}
                        </ModalAlert>
                    </Stack>
                    <Box mt={12}>
                        <SocialAuth>
                            Sign in
                        </SocialAuth>
                    </Box>
                </form>
            </FormCard>
        </Layout>
    );
};


export default SignIn;

画面のイメージ

コードの説明(コンポーネント部)

🔖フォームインプット部

フォームのインプットコントロールのコードはこんな感じになります。

    <FormTextInputControl
        id="email"
        isInvalid={ errors.email ? true : false }
        placeholder="email"
        registers={ register('email') }           // registerの引数はInputDataのkey
        errorMessage={ errors.email && errors.email.message }
    >
        Email
    </FormTextInputControl>
    ...
    <FormPasswordInputControl
        id="password"
        isInvalid={ errors.password ? true : false }
        placeholder="password"
        registers={ register('password') }    // registerの引数はInputDataのkey
        errorMessage={ errors.password && errors.password.message }
    >
        パスワード
    </FormPasswordInputControl>

コンポーネントは、FormTextInputControlFormPasswordInputControlの2種類を使用しています。FormTextInputControlはInput要素のtypeがtextのみで、FormPasswordInputControlはInput要素のtypeをtextとpasswordに切り替えられるようにしています。

register関数

まず、react-hook-formuseFormから出力されるregister関数について説明します。
rigister関数は普通に展開すると以下のようになります。

const { onChange, onBlur, name , ref } = register('email');

onBlurは要素からフォーカスが離れた時に発生するイベント属性
これらの属性を対象の要素に設定することで、その要素をreact-hook-formの管理下に置くことができるようになります。

error (useForm出力オブジェクト)

次にerrorsです。これは、useFormから出力されたformStateのオブジェクトです。validationの結果を格納します。
errorsにはエラーが発生した要素のname属性をKeyにしてエラーの内容が格納されます。emailでバリデーションエラーが発生した場合は、errors.emailでエラー内容を取得することができます。バリデーションが通った場合は、Keyも作成されないので、errors.emailはundefinedとなります。

🔖FormTextInputControlのコード

FormTextInputControlのコードを説明します。

type FormInputControlProps<T extends HTMLElement> = {
    children: ReactNode;
    id: string;
    isInvalid: boolean;
    placeholder: string;
    registers: {
        onChange: ChangeHandler;
        onBlur: ChangeHandler;
        name: string;
        ref: React.Ref<T>
    };
    errorMessage: string | undefined;
    dataTestId?: string;
    bg?: string;
}

export const FormTextInputControl: React.FC<FormInputControlProps<HTMLInputElement>> = (props) => {
    const { children, id, isInvalid, placeholder, registers, errorMessage,
                dataTestId = "form-text-input-control", bg="gray.200" } = props;
    return (
        <FormControl id={id} isInvalid={isInvalid}>
            <FormLabel>{children}</FormLabel>
            <Input
                data-testid={dataTestId}
                type="text"
                bg={bg}
                placeholder={placeholder}
                {...registers}
                />
            <FormErrorMessage>
                {errorMessage}
            </FormErrorMessage>
        </FormControl>
    )
};

※コード内のInputタグはChakraUIのコンポーネント(頭文字が大文字です)

registers属性の説明

ここで説明が必要になるのはregister属性かと思います。registers属性は独自に設定した属性名で、react-hook-formとは直接関係ありません。react-hook-formregister関数の出力をそのまま受け取る属性です。この属性をコンポーネントにセットすることで対象のコンポーネントがreact-hook-formと関連するようになります。上記のコードからこの属性の部分を抜き出してみます。

type FormInputControlProps<T extends HTMLElement> = {
    ...
    registers: {
        onChange: ChangeHandler;
        onBlur: ChangeHandler;
        name: string;
        ref: React.Ref<T>
    };
};

このregisters属性は、スプレッド構文を使って簡単にInput要素に設定することができます。

<Input {...register('email')}>

とても書き易くなりました。(反対に属性が隠蔽されるので何をやっているのか分かりにくいとも言えますが…)

其々の型について説明します。※stringはさすがに略

  • ChangeHandler
    ChangeHandlerは、react-hook-formの型なので、importするだけです。
    import { ChangeHandler } from "react-hook-form";
    
  • React.Ref
    問題なのは、ジェネリックを使っていrefの型です。
    React.RefはReactでDOM要素やReactコンポーネントのインスタンスへの参照を表しています。その対象のDOM要素はHTMLElmentかそのサブクラスである必要があります。ref: React.Ref<T>のTがHTMLElementもしくはそのサブクラスということです。それを定義しているのが、<T extend HTMLElement>の部分になります。
    HTMLElementを継承しているTという型に限定しますよ。と宣言しています。

react-hook-formに関する説明はこれでカバーした感じでしょうか。

🔖FormTextInputControlのコード

FromTextInputControlのコードは以下のようになります。Propsの型は共通です。

export const FormPasswordInputControl: React.FC<FormInputControlProps> = (props) => {
    const { children, id, isInvalid, placeholder, registers, errorMessage,
        dataTestId = 'test-form-password-input-control' } = props;
    const dataTestIdIcon = 'test-form-password-input-control-icon'      // 多分1つのテストにしか使わないので、propsにしない
    const [ show, setShow ] = useState(false);
    const showPW = () => setShow(!show);
    return (
        <FormControl id={id} isInvalid={isInvalid}>
            <FormLabel>{children}</FormLabel>
            <InputGroup>
                <Input
                    data-testid={dataTestId}
                    type={show ? 'text' : 'password'}
                    placeholder={placeholder}
                    {...registers}
                    />
                <InputRightElement color="gray.600" fontSize="1.4rem">
                    <a href="#">
                        {/* <Icon as={MdRemoveRedEye} w={7} h={7} mr={1} mt={2} _hover={{opacity: 0.6}} onClick={showPW} /> */}
                        <Icon as={show ? IoMdEyeOff : IoMdEye} w={7} h={7} mr={1} mt={2} _hover={{opacity: 0.6}} onClick={showPW} data-testid={dataTestIdIcon} />
                    </a>
                </InputRightElement>
            </InputGroup>
            <FormErrorMessage>
                {errorMessage}
            </FormErrorMessage>
        </FormControl>
    );
};

FormTextControlと異なるのは、type属性がtextとpasswordで切り替えられる機能がついていることです。
切り替え機能に関する部分を抜き出してみます。

    ...
    const [ show, setShow ] = useState(false);
    const showPW = () => setShow(!show);
    ...
    <InputGroup>
        <Input
            ...
            type={show ? 'text' : 'password'}
            ...
            />
        <InputRightElement color="gray.600" fontSize="1.4rem">
            <a href="#">
                <Icon as={show ? IoMdEyeOff : IoMdEye} w={7} h={7} mr={1} mt={2} _hover={{opacity: 0.6}} onClick={showPW} data-testid={dataTestIdIcon} />
            </a>
        </InputRightElement>
    </InputGroup>

  • useStateを使用してshow変数に、パスワードを表示するかどうかの表示フラグ(boolean)を保持します。
  • 表示フラグを切り替える関数をshowPW関数として定義します。
  • 今回のパスワードのように入力フィールドにアイコンを置く場合は、ChakraUIのInputGroupとInputRightElementなどを使用すると簡単に実現できます。
  • 表示フラグ(showの値)により InputコントロールのtypeとIconの形を切り替えます。切り替えている部分のコードはこんな感じになります。
    ...
    type={show ? 'text' : 'password'}
    ...
    <Icon as={show ? IoMdEyeOff : IoMdEye} ...>
    
    

これで、認証画面のロジックとコンポーネントの説明を終わります。
次は、パスワード忘れの処理について説明します。

Discussion