다양한 hook으로 회원가입 폼 구현해보기 - useState, useReducer, useForm

Doozuu·2025년 3월 27일
0

React

목록 보기
28/30

학습 목적

  • 지금까지 단순한 폼은 주로 useState로 입력값을 관리했다.
  • 하지만 폼이 복잡해지고 로딩 처리, 에러 처리, 유효성 검사 등의 로직이 추가되면 useState만으로는 입력값을 효율적으로 관리하기 어려워진다는걸 느꼈다.
  • 이번 기회를 통해 다양한 훅을 사용해서 회원가입 폼을 만들고 각 훅의 장단점을 비교해보려고 한다.

사전 지식

useState

React에서 제공하는 가장 대표적인 state 관리 hook이다.
함수형 컴포넌트에서 상태(state)를 관리할 수 있게 해준다.

형태

import {useState} from "react";

const [state, setState] = useState(initialValue);

초기값을 인자로 받아 해당 state와 state를 업데이트할 수 있는 함수를 반환한다.
state: 현재 상태 값을 저장한다.
setState: 상태를 업데이트하는 함수로, 호출 시 새로운 값을 전달하여 상태를 갱신한다.


useReducer

React에서 제공하는 또 다른 state 관리 hook으로, 더 복잡한 상태 로직을 처리하거나 여러 값에 대한 상태 업데이트가 필요할 때 유용하다.

형태

import {useReducer} from "react";

const [state, dispatch] = useReducer(reducer, initialValue);

useReducer는 두 개의 파라미터를 받는다. 첫 번째는 reducer 함수이고 두 번째는 초기값이다.
이 함수는 두 가지 값을 반환하는데, 첫 번째는 state이고 두 번째는 state를 변경할 수 있는 dispatch 함수이다.

reducer: 상태 업데이트 로직을 정의하는 함수로, 두 개의 인자를 받는다. 첫 번째는 현재 상태, 두 번째는 액션 객체이다. reducer 함수는 액션에 따라 새로운 상태를 반환한다.
dispatch: 상태를 변경하기 위해 사용하는 함수로, 액션 객체를 인자로 받는다. dispatch는 reducer 함수가 새로운 상태를 반환하도록 트리거한다.


useForm

useForm은 react-hook-form 라이브러리에서 제공하는 폼 관리 hook으로, React에서 폼을 간편하게 처리할 수 있게 해준다.
폼 데이터의 상태 관리, 검증, 제출 등을 효율적으로 다룰 수 있어 코드의 복잡성을 줄여준다.

형태

import { useForm } from "react-hook-form";

const { register, handleSubmit, watch, setValue, reset, formState } = useForm();

parameter로 폼 필드의 기본 상태를 설정하는 초기값을 넣을 수 있다. (필수는 아님)


설치

useForm을 사용하려면 react-hook-form을 설치해야 한다.

npm install react-hook-form

기능

useForm은 다음과 같이 다양한 기능을 제공한다.

register : input에 state를 연결(등록)하는 함수이다. 폼 데이터의 상태를 추적하고 관리한다. 이를 통해 input을 useForm과 연결하여 자동으로 상태를 관리한다.

<input {...register("username")} />

handleSubmit : 폼 제출 시 호출되는 함수이다. 제출 전에 데이터 검증을 수행하고 성공적인 제출 시 지정된 콜백 함수를 실행한다.

const onSubmit = data => {
  console.log(data);
};

<form onSubmit={handleSubmit(onSubmit)}>
  <input {...register("username")} />
  <button type="submit">Submit</button>
</form>

watch : 폼의 입력 필드 상태를 추적하는 함수이다. 특정 입력 필드나 전체 폼 데이터를 실시간으로 감지할 수 있다. 이 기능은 폼 데이터를 실시간으로 렌더링하거나, 동적으로 변경할 때 유용하다.

const username = watch("username");

setValue : setState와 비슷하게 폼의 상태 값을 변경하는 함수이다. 입력 필드의 값을 직접 설정할 수 있다.

setValue("username", "new value");

reset : 폼 데이터를 초기화하거나 기본값으로 되돌리는 함수이다. 폼의 값을 초기 상태로 되돌리고 싶을 때 사용한다.

reset({ username: "default value" });

formState : {errors, isSubmitting} : 폼의 상태 정보를 제공하는 객체로, errors와 isSubmitting 등을 포함한다. errors는 폼 필드의 검증 오류 상태를 나타내며, isSubmitting은 폼이 제출 중인지 여부를 나타낸다.

const { errors, isSubmitting } = formState;

아래 경우에 사용하면 유용하다.

  • 폼 상태 관리가 복잡하거나, 많은 필드를 처리해야 할 때
  • 폼 검증, 제출 및 리셋이 중요한 경우
  • 폼 상태를 효율적으로 관리하고 최적화하려는 경우

기능 요구사항

다음의 요구사항을 참고해서 회원가입 기능을 구현한다.

  • 입력값 : 이름, 나이, 별명, 아이디, 비밀번호
  • 유효성 검증 :
    • 이름(name) : 영문 대소문자 및 한글로만 구성되어야 한다. 2글자 이상이어야 한다. 필수로 입력받아야 한다.
    • 나이(age) : 숫자로만 구성되어야 한다. 1 이상의 양의 정수여야 한다. 필수로 입력받아야 한다.
    • 별명(nickname) : 영문 대소문자 및 한글로만 구성되어야 한다. 선택적으로 입력받는다.
    • 아이디(id) : 영문 대소문자 및 숫자를 포함할 수 있다. 5글자 이상, 20글자 이하여야 한다. 필수로 입력받아야 한다.
    • 비밀번호(password) : 영문 대소문자 및 숫자를 포함할 수 있다. 영문 대소문자와 숫자를 최소 1개 이상 포함해야 한다. 8글자 이상, 20글자 이하여야 한다. 필수로 입력받아야 한다.
  • 회원가입 요청 중에는 화면에 "로그인중입니다.." 메세지를 띄운다.
  • 가입에 성공하면 성공 알림창 띄운 후, 입력값을 전부 비우고 로그인 페이지로 이동한다.
  • 회원가입 중 에러가 발생할 경우 "회원가입 실패" 경고창을 띄운다.

useState로 구현해보기

1. 필요한 input state들을 선언해준다.

const [name, setName] = useState("");
const [age, setAge] = useState("");
const [nickname, setNickname] = useState("");
const [id, setId] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);

2. form을 만든다.

  • input의 value에 state 연결
  • onChange에 state 업데이트 로직 추가
  • onSubmit에 submit 함수 연결
  • placeholder 및 required, pattern 속성 추가
<form onSubmit={handleSignUp}>
  <div>
    <label>*이름</label>
    <input
      type="text"
      value={name}
      onChange={(e) => setName(e.target.value)}
      required
      pattern="^[가-힣a-zA-Z]{2,}$"
      placeholder="2글자 이상의 영문 또는 한글로 입력"
    />
  </div>
  <div>
    <label>*나이</label>
    <input
      type="number"
      value={age}
      onChange={(e) => setAge(e.target.value)}
      required
      pattern="^[1-9][0-9]*$"
      placeholder="1 이상의 양의 정수로 입력"
    />
  </div>
  <div>
    <label>별명 (선택)</label>
    <input
      type="text"
      value={nickname}
      onChange={(e) => setNickname(e.target.value)}
      pattern="^[a-zA-Z가-힣]*$"
      placeholder="영문 또는 한글로 입력 (선택)"
    />
  </div>
  <div>
    <label>*아이디</label>
    <input
      type="text"
      value={id}
      onChange={(e) => setId(e.target.value)}
      required
      pattern="^[a-zA-Z0-9]{5,20}$"
      placeholder="영문과 숫자를 포함한 5~20글자로 입력"
    />
  </div>
  <div>
    <label>*비밀번호</label>
    <input
      type="password"
      value={password}
      onChange={(e) => setPassword(e.target.value)}
      required
      pattern="^(?=.*[a-zA-Z])(?=.*\d).{8,20}$"
      placeholder="영문과 숫자를 포함해 8~20글자로 입력"
    />
  </div>
  <button type="submit" disabled={!isFormComplete}>
    가입하기
  </button>
</form>;

3. 폼 제출 함수를 작성한다.

// state 리셋 함수
const resetForm = () => {
  setName("");
  setAge("");
  setNickname("");
  setId("");
  setPassword("");
}

// 폼 제출 함수
const handleSignUp = async (e) => {
  e.preventDefault(); // 기본 제출 동작을 막는다.

  setIsLoading(true); // 로딩 상태를 true로 만든다.
  
  const userInfo = { name, age, nickname, id, password };

  try {
    await signUpUser(userInfo); // 회원가입 api를 호출한다.
    alert("회원가입 성공");
    resetForm(); // state 리셋
    navigate("/login"); // 로그인 페이지로 이동
  } catch (err) {
    console.error("회원가입 오류 :", err);
    alert("회원가입 실패"); // 경고창을 띄운다.
  } finally {
    setIsLoading(false); // 로딩 상태를 false로 만든다.
  }
};

전체 코드

import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { signUpUser } from "../apis/signUpUser";

const SignupForm = () => {
  const [name, setName] = useState("");
  const [age, setAge] = useState("");
  const [nickname, setNickname] = useState("");
  const [id, setId] = useState("");
  const [password, setPassword] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  const navigate = useNavigate();

  const isFormComplete = name && age && id && password;
  
  const resetForm = () => {
      setName("");
      setAge("");
      setNickname("");
      setId("");
      setPassword("");
  }
 
  const handleSignUp = async (e) => {
    e.preventDefault();

    setIsLoading(true);
    
    const userInfo = { name, age, nickname, id, password };

    try {
      await signUpUser(userInfo);
      alert("회원가입 성공");
      resetForm();
      navigate("/login");
    } catch (err) {
      console.error("회원가입 오류 :", err);
      alert("회원가입 실패");
    } finally {
      setIsLoading(false);
    }
  };

  return (
      <div>
        {isLoading && "로그인중입니다.."}
        <h1>회원가입</h1>
        <form onSubmit={handleSignUp}>
          <div>
            <label>*이름</label>
            <input
              type="text"
              value={name}
              onChange={(e) => setName(e.target.value)}
              required
              pattern="^[가-힣a-zA-Z]{2,}$"
              placeholder="2글자 이상의 영문 또는 한글로 입력"
            />
          </div>
          <div>
            <label>*나이</label>
            <input
              type="number"
              value={age}
              onChange={(e) => setAge(e.target.value)}
              required
              pattern="^[1-9][0-9]*$"
              placeholder="1 이상의 양의 정수로 입력"
            />
          </div>
          <div>
            <label>별명 (선택)</label>
            <input
              type="text"
              value={nickname}
              onChange={(e) => setNickname(e.target.value)}
              pattern="^[a-zA-Z가-힣]*$"
              placeholder="영문 또는 한글로 입력 (선택)"
            />
          </div>
          <div>
            <label>*아이디</label>
            <input
              type="text"
              value={id}
              onChange={(e) => setId(e.target.value)}
              required
              pattern="^[a-zA-Z0-9]{5,20}$"
              placeholder="영문과 숫자를 포함한 5~20글자로 입력"
            />
          </div>
          <div>
            <label>*비밀번호</label>
             <input
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                required
                pattern="^(?=.*[a-zA-Z])(?=.*\d).{8,20}$"
                placeholder="영문과 숫자를 포함해 8~20글자로 입력"
             />
          </div>
          <button type="submit" disabled={!isFormComplete}>
            가입하기
          </button>
        </form>
        <a onClick={() => navigate("/login")}>로그인</a>
      </div>
  );
};

export default SignupForm;

state를 객체로 묶어 개선해보기

상태를 객체로 관리해주면 하나의 useState 만으로도 여러 상태를 관리할 수 있다.
값을 업데이트하는 부분도 간결하게 나타낼 수 있다.

import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { signUpUser } from "../apis/signUpUser";

const SignupForm = () => {
  const [formData, setFormData] = useState({
    name: "",
    age: "",
    nickname: "",
    id: "",
    password: ""
  });
  const [isLoading, setIsLoading] = useState(false);

  const navigate = useNavigate();

  const isFormComplete = formData.name && formData.age && formData.id && formData.password;

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setFormData((prevData) => ({
      ...prevData,
      [name]: value
    }));
  };

  const resetForm = () => {
    setFormData({
      name: "",
      age: "",
      nickname: "",
      id: "",
      password: ""
    });
  };

  const handleSignUp = async (e) => {
    e.preventDefault();

    setIsLoading(true);

    const userInfo = { ...formData };

    try {
      await signUpUser(userInfo);
      alert("회원가입 성공");
      resetForm();
      navigate("/login");
    } catch (err) {
      console.error("회원가입 오류 :", err);
      alert("회원가입 실패");
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      {isLoading && "로그인중입니다.."}
      <h1>회원가입</h1>
      <form onSubmit={handleSignUp}>
        <div>
          <label>*이름</label>
          <input
            type="text"
            name="name"
            value={formData.name}
            onChange={handleInputChange}
            required
            pattern="^[가-힣a-zA-Z]{2,}$"
            placeholder="2글자 이상의 영문 또는 한글로 입력"
          />
        </div>
        <div>
          <label>*나이</label>
          <input
            type="number"
            name="age"
            value={formData.age}
            onChange={handleInputChange}
            required
            pattern="^[1-9][0-9]*$"
            placeholder="1 이상의 양의 정수로 입력"
          />
        </div>
        <div>
          <label>별명 (선택)</label>
          <input
            type="text"
            name="nickname"
            value={formData.nickname}
            onChange={handleInputChange}
            pattern="^[a-zA-Z가-힣]*$"
            placeholder="영문 또는 한글로 입력 (선택)"
          />
        </div>
        <div>
          <label>*아이디</label>
          <input
            type="text"
            name="id"
            value={formData.id}
            onChange={handleInputChange}
            required
            pattern="^[a-zA-Z0-9]{5,20}$"
            placeholder="영문과 숫자를 포함한 5~20글자로 입력"
          />
        </div>
        <div>
          <label>*비밀번호</label>
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleInputChange}
            required
            pattern="^(?=.*[a-zA-Z])(?=.*\d).{8,20}$"
            placeholder="영문과 숫자를 포함해 8~20글자로 입력"
          />
        </div>
        <button type="submit" disabled={!isFormComplete}>
          가입하기
        </button>
      </form>
      <a onClick={() => navigate("/login")}>로그인</a>
    </div>
  );
};

export default SignupForm;

useReducer로 구현해보기

1. initialState를 정의한다.

const initialState = {
  name: "",
  age: "",
  nickname: "",
  id: "",
  password: ""
};

2. reducer 함수를 만든다.

const formReducer = (state, action) => {
  switch (action.type) {
    case "SET_FIELD":
      return {
        ...state,
        [action.field]: action.value
      };
    case "RESET":
      return initialState;
    default:
      return state;
  }
};

3. setState 대신 dispatch를 사용한다.

const handleInputChange = (e) => {
  const { name, value } = e.target;
  dispatch({ type: "SET_FIELD", field: name, value });
};

const resetForm = () => {
  dispatch({ type: "RESET" });
};

전체 코드

import { useReducer, useState } from "react";
import { useNavigate } from "react-router-dom";
import { signUpUser } from "../apis/signUpUser";

const initialState = {
  name: "",
  age: "",
  nickname: "",
  id: "",
  password: ""
};

const formReducer = (state, action) => {
  switch (action.type) {
    case "SET_FIELD":
      return {
        ...state,
        [action.field]: action.value
      };
    case "RESET":
      return initialState;
    default:
      return state;
  }
};

const SignupForm = () => {
  const [formData, dispatch] = useReducer(formReducer, initialState);
  const [isLoading, setIsLoading] = useState(false);
  const navigate = useNavigate();

  const isFormComplete =
    formData.name && formData.age && formData.id && formData.password;

  const handleInputChange = (e) => {
    const { name, value } = e.target;
    dispatch({ type: "SET_FIELD", field: name, value });
  };

  const resetForm = () => {
    dispatch({ type: "RESET" });
  };

  const handleSignUp = async (e) => {
    e.preventDefault();

    setIsLoading(true);

    const userInfo = { ...formData };

    try {
      await signUpUser(userInfo);
      alert("회원가입 성공");
      resetForm();
      navigate("/login");
    } catch (err) {
      console.error("회원가입 오류 :", err);
      alert("회원가입 실패");
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      {isLoading && "로그인중입니다.."}
      <h1>회원가입</h1>
      <form onSubmit={handleSignUp}>
        <div>
          <label>*이름</label>
          <input
            type="text"
            name="name"
            value={formData.name}
            onChange={handleInputChange}
            required
            pattern="^[가-힣a-zA-Z]{2,}$"
            placeholder="2글자 이상의 영문 또는 한글로 입력"
          />
        </div>
        <div>
          <label>*나이</label>
          <input
            type="number"
            name="age"
            value={formData.age}
            onChange={handleInputChange}
            required
            pattern="^[1-9][0-9]*$"
            placeholder="1 이상의 양의 정수로 입력"
          />
        </div>
        <div>
          <label>별명 (선택)</label>
          <input
            type="text"
            name="nickname"
            value={formData.nickname}
            onChange={handleInputChange}
            pattern="^[a-zA-Z가-힣]*$"
            placeholder="영문 또는 한글로 입력 (선택)"
          />
        </div>
        <div>
          <label>*아이디</label>
          <input
            type="text"
            name="id"
            value={formData.id}
            onChange={handleInputChange}
            required
            pattern="^[a-zA-Z0-9]{5,20}$"
            placeholder="영문과 숫자를 포함한 5~20글자로 입력"
          />
        </div>
        <div>
          <label>*비밀번호</label>
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleInputChange}
            required
            pattern="^(?=.*[a-zA-Z])(?=.*\d).{8,20}$"
            placeholder="영문과 숫자를 포함해 8~20글자로 입력"
          />
        </div>
        <button type="submit" disabled={!isFormComplete}>
          가입하기
        </button>
      </form>
      <a onClick={() => navigate("/login")}>로그인</a>
    </div>
  );
};

export default SignupForm;

useForm으로 구현해보기

1. useForm 정의

  const {
    register,
    handleSubmit,
    formState: { errors },
    reset
  } = useForm();

2. input에 register를 적용한다.

pattern에서 message 속성을 추가하면 에러 메세지를 간편하게 보여줄 수 있다.

<div>
  <label>*이름</label>
  <input
    type="text"
    {...register("name", {
      required: "이름은 필수 항목입니다.",
      pattern: {
        value: /^[가-힣a-zA-Z]{2,}$/,
        message: "2글자 이상의 영문 또는 한글로 입력",
      },
    })}
    placeholder="2글자 이상의 영문 또는 한글로 입력"
  />
  {errors.name && <p>{errors.name.message}</p>}
</div>;

전체 코드

import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { signUpUser } from "../apis/signUpUser";

const SignupForm = () => {
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset
  } = useForm();
  const [isLoading, setIsLoading] = useState(false);
  const navigate = useNavigate();

  const onSubmit = async (data) => {
    setIsLoading(true);

    try {
      await signUpUser(data);
      alert("회원가입 성공");
      reset(); // 폼 리셋
      navigate("/login");
    } catch (err) {
      console.error("회원가입 오류 :", err);
      alert("회원가입 실패");
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      {isLoading && "로그인중입니다.."}
      <h1>회원가입</h1>
      <form onSubmit={handleSubmit(onSubmit)}>
        <div>
          <label>*이름</label>
          <input
            type="text"
            {...register("name", {
              required: "이름은 필수 항목입니다.",
              pattern: {
                value: /^[가-힣a-zA-Z]{2,}$/,
                message: "2글자 이상의 영문 또는 한글로 입력"
              }
            })}
            placeholder="2글자 이상의 영문 또는 한글로 입력"
          />
          {errors.name && <p>{errors.name.message}</p>}
        </div>

        <div>
          <label>*나이</label>
          <input
            type="number"
            {...register("age", {
              required: "나이는 필수 항목입니다.",
              pattern: {
                value: /^[1-9][0-9]*$/,
                message: "1 이상의 양의 정수로 입력"
              }
            })}
            placeholder="1 이상의 양의 정수로 입력"
          />
          {errors.age && <p>{errors.age.message}</p>}
        </div>

        <div>
          <label>별명 (선택)</label>
          <input
            type="text"
            {...register("nickname", {
              pattern: {
                value: /^[a-zA-Z가-힣]*$/,
                message: "영문 또는 한글로 입력"
              }
            })}
            placeholder="영문 또는 한글로 입력 (선택)"
          />
          {errors.nickname && <p>{errors.nickname.message}</p>}
        </div>

        <div>
          <label>*아이디</label>
          <input
            type="text"
            {...register("id", {
              required: "아이디는 필수 항목입니다.",
              pattern: {
                value: /^[a-zA-Z0-9]{5,20}$/,
                message: "영문과 숫자를 포함한 5~20글자로 입력"
              }
            })}
            placeholder="영문과 숫자를 포함한 5~20글자로 입력"
          />
          {errors.id && <p>{errors.id.message}</p>}
        </div>

        <div>
          <label>*비밀번호</label>
          <input
            type="password"
            {...register("password", {
              required: "비밀번호는 필수 항목입니다.",
              pattern: {
                value: /^(?=.*[a-zA-Z])(?=.*\d).{8,20}$/,
                message: "영문과 숫자를 포함해 8~20글자로 입력"
              }
            })}
            placeholder="영문과 숫자를 포함해 8~20글자로 입력"
          />
          {errors.password && <p>{errors.password.message}</p>}
        </div>

        <button type="submit" disabled={isLoading}>
          가입하기
        </button>
      </form>
      <a onClick={() => navigate("/login")}>로그인</a>
    </div>
  );
};

export default SignupForm;

useState vs useReducer vs useForm

  • useState
    장점: 러닝 커브가 낮고, 상태를 객체로 관리하면 유용하다. 간단한 폼의 경우 사용하면 괜찮을 것 같다.
    단점: 관리해야 하는 값이 많아지거나 요구 사항이 복잡해질 경우 한계가 존재한다.
  • useReducer
    장점: 상태 업데이트 로직을 추상화하고, 액션을 이용해 직관적으로 표현할 수 있다. 예를 들어, setState("") 대신 dispatch({ type: "reset" })만 알면 충분하다.
    단점: 러닝 커브가 좀 높다. 리듀서와 디스패치 개념에 익숙해지는데 시간이 걸릴 수 있다. 또한, useState와 마찬가지로 요구 사항이 복잡해질 경우 한계가 있을 수 있다.
  • useForm
    장점: 상태 값 초기화 및 에러 처리, 로딩 상태 관리를 간편하게 할 수 있다. 복잡한 요구 사항을 처리하는 데 유용하다.
    단점: 러닝 커브가 좀 높다. 다양한 기능을 제공하므로 제대로 이해하고 사용하려면 시간이 필요할 수 있다.

참고한 블로그
https://kyung-a.tistory.com/39

profile
모든게 새롭고 재밌는 프론트엔드 새싹

0개의 댓글