React/타입스크립트로 배우는 리액트(React.js) : 기초부터 최신 기술까지

섹션 10. 폼 다루기

favor 2026. 5. 7. 02:52

폼 다루기란?

폼(forms) : 사용자가 데이터를 입력하고, 제출할 수 있도록 하는 UI 요소

ex) 텍스트 필드, 체크박스, 라디오버튼, 드롭 다운 목록 등 

 

폼 요소를 다루는 방법

  • 제어 컴포넌트 방식
    • React의 상태를 활용해서 폼 요소를 제어하는 방식
  • 비제어 컴포넌트 방식
    • React의 상태를 활용하지 않고 DOM 자체에서 상태를 관리하게 하는 방식 

 

제어 컴포넌트 - 한 줄 입력 요소

readOnly, disabled : 사용자가 폼 요소의 value 값을 직접 수정하는 것을 막을 때 사용하는 속성 

∴ 초기값을 디스플레이하는 용도로만 사용

ㄴ readOnly : 데이터를 서버에 보낼 때 포함 / disabled : 데이터가 전송되지 않고 제외됨 

 

* 한 개의 한 줄 입력 요소

// Input.tsx

import { useState } from "react";

export default function Input() {

  const [input, setInput] = useState("");
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInput(e.target.value);
  };

  return (
    <>
      <form>
        <h1>Input: {input}</h1>
        <input type="text" value={input} onChange={handleInputChange} />
      </form>
    </>
  );
}

^ React의 상태(state)와 입력창의 값(value)을 동기화하는 코드 

 

* 다중 Input 관리

1인 1상태 방식 : 입력 요소가 많아질수록 useState와 핸들러 함수가 계속 추가됨 --> 코드가 길어짐 

∴ 객체 통합 방식 사용 --> 상태 하나와 함수 하나로 모든 입력 요소를 처리 

객체 속성의 키 값과 입력 요소의 name 속성 값을 일치시킴 

 

* 여러 개의 한 줄 입력 요소

// Input.tsx

import { useState } from "react";

export default function Input() {

  const [formState, setFormState] = useState({
    email: "",
    password: ""
  });

  const handleFormStateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFormState((prevFormState) => ({
      ...prevFormState,
      [e.target.name] : e.target.value,
    }))
  }


  return (
    <>
      <form>
        <h1>Input: {formState.email} / Pw: {formState.password}</h1>
        <input type="text" name="email" value={formState.email} onChange={handleFormStateChange} />
        <input type="password" name="password" value={formState.password} onChange={handleFormStateChange} /> 
      </form>
    </>
  );
}

^ name="email" / name="password"

 

제어 컴포넌트 - 체크박스

* 한 개의 체크박스

// CheckBox.tsx

import { useState } from "react";

export default function CheckBox () {

  const [chk1, setChk1] = useState(false);
  
  const handleChkChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setChk1(e.target.checked);
  };


  return (
    <div>
      <input type="checkbox" id="item1" checked={chk1} onChange={handleChkChange} /> 
      <label htmlFor="item1">아이템 1 ({chk1 ? "선택됨" : "선택되지 않음"})</label>
    </div>
  );
}

^ React의 상태(state)와 입력창의 값(value)을 동기화하는 코드 

 

* 여러 개의 체크박스

// CheckBox.tsx

import { useState } from "react";

export default function CheckBox () {

  const [formState, setFormState] = useState({
    chk1: false,
    chk2: false
  });
  
  const handleFormStateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFormState((prevFormState) => ({
      ...prevFormState,
      [e.target.name] : e.target.checked
    }))
  };

  return (
    <div>
      <input type="checkbox" id="item1" name="chk1" checked={formState.chk1} onChange={handleFormStateChange} /> 
      <label htmlFor="item1">아이템 1 ({formState.chk1 ? "선택됨" : "선택되지 않음"})</label>

      <input type="checkbox" id="item2" name="chk2" checked={formState.chk2} onChange={handleFormStateChange} /> 
      <label htmlFor="item2">아이템 2 ({formState.chk2 ? "선택됨" : "선택되지 않음"})</label>
    </div>
  );
}

^ name="chk1" / name="chk2"

 

제어 컴포넌트 - 라디오버튼

* 한 개의 라디오버튼

// Radio.tsx

import { useState } from "react";

export default function Radio() {

  const [selectedValue, setSelectedValue] = useState("option1");

  const handleRadioChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSelectedValue(e.target.value);
  };

  return (
    <div>
      <label>
        <input type="radio" name="option" value="option1" checked={selectedValue === "option1"} onChange={handleRadioChange} />
        옵션 1
      </label>
      <label>
        <input type="radio" name="option" value="option2" checked={selectedValue === "option2"} onChange={handleRadioChange} />
        옵션 2
      </label>
      <label>
        <input type="radio" name="option" value="option3" checked={selectedValue === "option3"} onChange={handleRadioChange} />
        옵션 3
      </label>
    </div>
  );
}

 

* 라디오버튼과 객체 상태 관리

  • 텍스트 인풋
    • 이름, 이메일, 비밀번호는 각각 독립적인 데이터
    • --> { name, email, pw }라는 객체가 필요
  • 라디오버튼
    • 남성, 여성, 선택 안 함 버튼이 3개 있어도, 최종적으로 필요한 데이터는 '성별'이라는 하나의 값
    • --> 그룹 전체가 하나의 정보를 담기 때문에 useState("")라는 문자열 상태 하나면 충분

 

* 여러 개의 라디오버튼

// Radio.tsx

import { useState } from "react";

export default function Radio() {

  const [formState, setFormState] = useState({
    option: "",
    color: ""
  });

  const handleFormStateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setFormState((prevFormState) => ({
      ...prevFormState,
      [e.target.name] : e.target.value
    }))
  };

  return (
    <section>
      <div>
        <label>
          <input
            type="radio"
            name="option"
            value="option1"
            checked={formState.option === "option1"}
            onChange={handleFormStateChange}
          />
          옵션 1
        </label>
        <label>
          <input
            type="radio"
            name="option"
            value="option2"
            checked={formState.option === "option2"}
            onChange={handleFormStateChange}
          />
          옵션 2
        </label>
        <label>
          <input
            type="radio"
            name="option"
            value="option3"
            checked={formState.option === "option3"}
            onChange={handleFormStateChange}
          />
          옵션 3
        </label>
      </div>

      <div>
        <label>
          <input
            type="radio"
            name="color"
            value="color1"
            checked={formState.color === "color1"}
            onChange={handleFormStateChange}
          />
          컬러 옵션 1
        </label>
        <label>
          <input
            type="radio"
            name="color"
            value="color2"
            checked={formState.color === "color2"}
            onChange={handleFormStateChange}
          />
          컬러 옵션 2
        </label>
        <label>
          <input
            type="radio"
            name="color"
            value="color3"
            checked={formState.color === "color3"}
            onChange={handleFormStateChange}
          />
          컬러 옵션 3
        </label>
      </div>
    </section>
  );
}

^ name="option" / name="color"

 

제어 컴포넌트 - 여러 줄 입력 요소

HTMLTextAreaElemet 타입 

* 한 개의 여러 줄 입력 요소 

// Textarea.tsx

import { useState } from "react";

export default function Textarea() {

  const [text, setText] = useState("");

  const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setText(e.target.value);
  };

  return (
    <div>
      <textarea value={text} onChange={handleTextChange}/>
      <p>입력된 텍스트: {text}</p>
    </div>
  );
}

 

* 여러 개의 여러 줄 입력 요소

// Textarea.tsx

import { useState } from "react";

export default function Textarea() {

  const [formState, setFormState] = useState({
    desc: "",
    introduce: ""
  })

  const handleFormStateChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setFormState((prevFormState) => ({
      ...prevFormState,
      [e.target.name] : e.target.value
    }))
  }

  return (
    <div>
      <textarea name="desc" value={formState.desc} onChange={handleFormStateChange}/>
      <p>입력된 텍스트: {formState.desc}</p>

      <textarea name="introduce" value={formState.introduce} onChange={handleFormStateChange}/>
      <p>입력된 텍스트: {formState.introduce}</p>
    </div>
  );
}

 

비제어 컴포넌트 - 한 줄 입력 요소

useRef 훅의 기본적인 문법 형태

const ref = useRef<Type>(initialState)

  • Type
    • 타입 추론에 의해서 생략 가능
    • 태그와 연결할 때는 구체적인 타입을 적어주는 것이 좋음 

 

* 비제어 컴포넌트 방식은 실시간으로 사용자가 입력한 값을 가져올 수는 없음
∵ 상태(useState)를 사용하지 않기 때문 

ㄴ 참조된 DOM 노드에 직접 접근하여 필요할 때만 입력 요소에 입력된 값을 조회할 수 있음 

 

* 한 개의 한 줄 입력 요소

// Input.tsx

import { useRef } from "react";

export default function Input() {

  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log(inputRef.current?.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" ref={inputRef} />
      <button type="submit">Submit</button>
    </form>
  );
}

 

* 여러 데이터를 하나의 객체로 관리하는 제어 방식과 달리, 비제어 방식에서는 ref 객체 하나가 단일 DOM 노드를 가리키도록 설계하는 것이 정석

 

비제어 컴포넌트 - 체크박스

// Checkbox.tsx

import { useRef } from "react";

export default function Checkbox() {

  const privacyRef = useRef<HTMLInputElement>(null);
  const termsRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const privacy = privacyRef.current?.checked;
    if (!privacy) {
      alert("개인정보에 동의해주세요");
      return;
    }

    const terms = termsRef.current?.checked;
    if (!terms) {
      alert("약관에 동의해주세요");
      return;
    }

    console.log(privacyRef.current?.checked);
    console.log(termsRef.current?.checked);
  };


  return (
    <>
      <form onSubmit={handleSubmit}>
        <div>
          <input type="checkbox" ref={privacyRef} />
          <label>개인정보동의</label>
        </div>

        <div>
          <input type="checkbox" ref={termsRef} />
          <label>약관동의</label>
        </div>
        <button type="submit">Submit</button>
      </form>
    </>
  );
}

^ privacyRef / termsRef

// Checkbox.tsx

import { useRef } from "react";

export default function Checkbox() {
  const fruitRef = useRef<HTMLInputElement[]>([]);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const selectedFruits = fruitRef.current
      .filter((fruit) => fruit.checked)
      .map((fruit) => fruit.value);

    console.log(selectedFruits);
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <div>
          <input
            type="checkbox"
            value={"사과"}
            ref={(el) => {
              fruitRef.current[0] = el!;
            }}
          />
          <label>사과</label>
        </div>

        <div>
          <input
            type="checkbox"
            value={"바나나"}
            ref={(el) => {
              fruitRef.current[1] = el!;
            }}
          />
          <label>바나나</label>
        </div>

        <div>
          <input
            type="checkbox"
            value={"오렌지"}
            ref={(el) => {
              fruitRef.current[2] = el!;
            }}
          />
          <label>오렌지</label>
        </div>
        <button type="submit">Submit</button>
      </form>
    </>
  );
}

^ ref 배열과 콜백 함수를 이용해 여러 체크박스의 선택된 값을 한 번에 수집 

 

비제어 컴포넌트 - 라디오버튼

new FormData() : name 속성이 붙은 모든 입력값을 (key: name, value: 값) 형태로 정리해서 뽑아줌 

// Radio.tsx

import { useRef } from "react";

export default function Radio() {

  const formElRef = useRef<HTMLFormElement>(null);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const formData = new FormData(formElRef.current!);
    console.log(formData.get("option")); 

  };

  return (
    <form ref={formElRef} onSubmit={handleSubmit}>
      <div>
        <label>
          <input type="radio" name="option" value="option1" defaultChecked />
          옵션 1
        </label>
      </div>

      <div>
        <label>
          <input type="radio" name="option" value="option2" />
          옵션 2
        </label>
      </div>

      <div>
        <label>
          <input type="radio" name="option" value="option3" />
          옵션 3
        </label>
      </div>

      <button type="submit">제출</button>
    </form>
  );
}

^ formData.get("option") --> name이 option인 요소들 중에서 현재 선택된 것의 value만 추출  

 

비제어 컴포넌트 - 여러 줄 입력 요소

HTMLTextAreaElemet 타입 

// Textarea.tsx

import { useRef } from "react";

export default function Textarea() {

  const descRef = useRef<HTMLTextAreaElement>(null);
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    console.log(descRef.current?.value);

  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <textarea name="desc" ref={descRef} />
        <button type="submit">Submit</button>
      </form>
    </>
  );
}

 

폼 제어 고급 - ref 전달하기

ref 객체는 결국 JSX 요소의 참조 관계를 형성하는 것이기 때문에 자바스크립트로 따지면 querySelector() 메소드와 동일한 역할을 한다고 생각

 

ref 속성을 자식 컴포넌트로 전달할 때 버전별 문법 차이

1. React 19

// App.tsx

import { useRef } from "react";
import Input from "./components/Input";

export default function App() {

  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
  
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <Input ref={inputRef} />
        <button type="submit">제출</button>
      </form>
    </>
  );
}
// Input.tsx

import { RefObject } from "react";

export default function Input({
  ref,
}: {
  ref: RefObject<HTMLInputElement | null>;
}) {
  return (
    <>
      <input type="text" ref={ref} />
    </>
  );
}

 

2. React 18

forwardRef() 함수 사용

// App.tsx

import { useRef } from "react";
import Input from "./components/Input";

export default function App() {

  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
  
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <Input type="password" ref={inputRef} />
        <button type="submit">제출</button>
      </form>
    </>
  );
}
// Input.tsx

import { forwardRef } from "react";

export default forwardRef<HTMLInputElement, { type: string }>(function Input(
  { type },
  ref,
) {
  return (
    <>
      <input type={type} ref={ref} />
    </>
  );
});

 

폼 제어 고급 - 커스텀 훅

제어 컴포넌트 방식은 모든 입력 요소마다 상태(state)와 업데이트 함수(setter)를 한 쌍으로 선언해야 함

∴ 입력 폼이 많아지거나 복잡해질수록 코드의 가독성이 떨어지고 유지보수가 어려워짐

--> 이를 해결하기 위해 반복되는 로직을 별도의 함수로 분리하여 재사용하는 커스텀 훅 패턴이 권장됨 

 

커스텀 훅(Custom Hook) : React에서 제공하는 훅을 조합하여 나만의 훅을 만드는 것

ㄴ 코드 다이어트, 범용성 확보 및 유지보수 용이

 

// App.tsx

import useInput from "./hooks/useInput";

export default function App() {
  const { value: email, handleValueChange: handleEmailChange } = useInput("");

  const { value: password, handleValueChange: handlePasswordChange } = useInput("");

  const { value: name, handleValueChange: handleNameChange } = useInput("");

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log({ email, password, name });
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="email"
          value={email}
          onChange={handleEmailChange}
        />
        <input
          type="password"
          placeholder="password"
          value={password}
          onChange={handlePasswordChange}
        />
        <input
          type="text"
          placeholder="name"
          value={name}
          onChange={handleNameChange}
        />
        <button type="submit">제출</button>
      </form>
    </>
  );
}
// hooks/useInput.tsx

import { useState } from "react";

export default function useInput(initialValue = "") {

  const [value, setValue] = useState(initialValue);
  const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  };

  return {
    value,
    handleValueChange
  };
}

 

벨리데이션 - 기본

벨리데이션(Validation, 유효성 검사) : 폼 요소의 값이 서비스에서 정한 규칙에 맞는지 확인하는 과정 

  • required
    • 필수 입력
  • minLength={num}
    • 최소 길이 제한
  • maxLength={num}
    • 최대 길이 제한 

 

벨리데이션 - 커스텀 

커스텀 벨리데이션(Custom Validation) : HTML5 표준 속성을 넘어, 개발자가 직접 정의한 비즈니스 로직을 통해 데이터의 유효성을 검사하는 것

ㄴ 에러 핸들링도 커스텀 가능 

 

// App.tsx

import useInput from "./hooks/useInput";
import { validateEmail, validatePassword, validateName } from "./utils/validation";

export default function App() {
  const { value: email, handleValueChange: handleEmailChange } = useInput("");
  const { value: password, handleValueChange: handlePasswordChange } =
    useInput("");
  const { value: name, handleValueChange: handleNameChange } = useInput("");

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // 이메일 검증 
    const emailError = validateEmail(email);
    if (emailError) {
      alert(emailError);
      return;
    }

    // 비밀번호 검증 
    const passwordError = validatePassword(password);
    if (passwordError) {
      alert(passwordError);
      return;
    }

    // 이름 검증 
    const nameError = validateName(name);
    if (nameError) {
      alert(nameError);
      return;
    }

    console.log({ email, password, name });
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="email"
          value={email}
          onChange={handleEmailChange}
          required
        />
        <input
          type="password"
          placeholder="password"
          value={password}
          onChange={handlePasswordChange}
          minLength={4}
          maxLength={6}
          required
        />
        <input
          type="text"
          placeholder="name"
          value={name}
          onChange={handleNameChange}
          required
        />
        <button type="submit">제출</button>
      </form>
    </>
  );
}
// utils/validation.ts

export const validateEmail = (email: string) => {
  if (!email) return "이메일은 필수입니다.";
  if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email))
    return "유효한 이메일 주소를 입력하세요.";
  if (!/^[a-zA-Z0-9._%+-]+@(gmail\.com|naver\.com)$/.test(email)) {
    return "이메일 주소는 gmail.com 또는 naver.com 형식만 허용됩니다.";
  }
};

export const validatePassword = (password: string) => {
  if (!password) return "비밀번호는 필수입니다.";
  if (password.length < 8) return "비밀번호는 8자 이상이어야 합니다.";
  if (!/[A-Za-z]/.test(password))
    return "비밀번호에는 최소한 하나의 영문자가 포함되어야 합니다.";
  if (!/[0-9]/.test(password))
    return "비밀번호에는 최소한 하나의 숫자가 포함되어야 합니다.";
  if (!/[!@#$%^&*(),.?":{}|<>]/.test(password))
    return "비밀번호에는 최소한 하나의 특수문자가 포함되어야 합니다.";
};

export const validateName = (name: string) => {
  if (!name) return "사용자 이름은 필수입니다.";
  if (name.length < 3 || name.length > 20)
    return "사용자 이름은 3~20자 사이여야 합니다.";
  if (!/^[A-Za-z0-9]+$/.test(name))
    return "사용자 이름은 영문자와 숫자만 포함해야 합니다.";
};

 

벨리데이션 - 커스텀 훅 통합 

커스텀 훅과 커스텀 벨리데이션을 통합

 

// App.tsx

import useInput from "./hooks/useInput";
import {
  validateEmail,
  validateName,
  validatePassword,
} from "./utils/validation";

export default function App() {
  const { value: email, handleValueChange: handleEmailChange, error: emailError } = useInput("", validateEmail);
  const { value: password, handleValueChange: handlePasswordChange, error: passwordError } = useInput("", validatePassword);
  const { value: name, handleValueChange: handleNameChange, error: nameError } = useInput("", validateName);

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // 이메일 검증
    if (emailError) {
      alert(emailError);
      return;
    }

    // 비밀번호 검증
    if (passwordError) {
      alert(passwordError);
      return;
    }

    // 이름 검증
    if (nameError) {
      alert(nameError);
      return;
    }

    console.log({ email, password, name });
  };

  return (
    <>
      <form onSubmit={handleSubmit}>
        <input
          type="email"
          placeholder="email"
          value={email}
          onChange={handleEmailChange}
          required
        />
        {emailError && <p>{emailError}</p>}
        <input
          type="password"
          placeholder="password"
          value={password}
          onChange={handlePasswordChange}
          required
        />
        {passwordError && <p>{passwordError}</p>}
        <input
          type="text"
          placeholder="name"
          value={name}
          onChange={handleNameChange}
          required
        />
        {nameError && <p>{nameError}</p>}
        <button type="submit">제출</button>
      </form>
    </>
  );
}
// hooks/useInput.tsx

import { useState } from "react";

export default function useInput(
  initialValue = "",
  validateFn: (value: string) => string | undefined,
) {
  const [error, setError] = useState("");
  const [value, setValue] = useState(initialValue);
  const handleValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
    setError(validateFn(e.target.value) || "");
  };

  return {
    value,
    handleValueChange,
    error
  };
}
// utils/validation.ts

export const validateEmail = (email: string) => {
  if (!email) return "이메일은 필수입니다.";
  if (!/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(email))
    return "유효한 이메일 주소를 입력하세요.";
  if (!/^[a-zA-Z0-9._%+-]+@(gmail\.com|naver\.com)$/.test(email)) {
    return "이메일 주소는 gmail.com 또는 naver.com 형식만 허용됩니다.";
  }
};

export const validatePassword = (password: string) => {
  if (!password) return "비밀번호는 필수입니다.";
  if (password.length < 8) return "비밀번호는 8자 이상이어야 합니다.";
  if (!/[A-Za-z]/.test(password))
    return "비밀번호에는 최소한 하나의 영문자가 포함되어야 합니다.";
  if (!/[0-9]/.test(password))
    return "비밀번호에는 최소한 하나의 숫자가 포함되어야 합니다.";
  if (!/[!@#$%^&*(),.?":{}|<>]/.test(password))
    return "비밀번호에는 최소한 하나의 특수문자가 포함되어야 합니다.";
};

export const validateName = (name: string) => {
  if (!name) return "사용자 이름은 필수입니다.";
  if (name.length < 3 || name.length > 20)
    return "사용자 이름은 3~20자 사이여야 합니다.";
  if (!/^[A-Za-z0-9]+$/.test(name))
    return "사용자 이름은 영문자와 숫자만 포함해야 합니다.";
};