Skip to Content
WebReactReact 중급08. 라이브러리 없는 폼 검증과 에러 표시

이번 편의 결과물: 추가 폼에서 빈 제목·중복 도서 등을 제출 전에 막고, 필드별 에러 메시지를 화면과 스크린 리더 모두에 전달합니다. 다루는 개념: 제출 전 검증 함수 설계, 필드별 에러 상태와 인라인 메시지, aria-invalid/aria-describedby로 에러 연결, useActionState와 검증 결합

이 편에서 만드는 파일

bookshelf/src/ └── pages/ └── NewBookPage.jsx ~ 검증 함수 분리, 필드별 에러 상태, aria 속성 추가

개념 정리

05편의 NewBookPage는 에러를 문자열 하나(state.error)로만 관리했습니다. 제목이 비어도, 저자가 비어도 같은 문구가 폼 맨 아래에 한 번 나옵니다. 이번 편은 필드마다 에러를 구분합니다.

검증 항목조건에러 위치
제목빈 문자열이면 안 됨title 입력 아래
저자빈 문자열이면 안 됨author 입력 아래
쪽수입력했다면 0 이상의 숫자여야 함pages 입력 아래
제목 중복이미 저장된 책과 제목(공백 제거·대소문자 무시)이 같으면 안 됨title 입력 아래

검증 함수는 컴포넌트 밖에 순수 함수로 둡니다. 입력값 객체를 받아 { 필드명: 에러문구 } 형태의 객체를 반환하고, 에러가 없는 필드는 키 자체를 만들지 않습니다.

에러를 시각적으로만 빨간 글씨로 보여주면 스크린 리더 사용자는 어떤 입력에 문제가 있는지 알기 어렵습니다. 두 속성을 짝으로 씁니다.

속성위치역할
aria-invalid입력 요소이 입력이 검증에 실패했음을 보조 기술에 알림
aria-describedby입력 요소에러 메시지 요소의 id를 가리켜 두 요소를 연결
role="alert"에러 메시지 요소메시지가 나타나는 순간 스크린 리더가 즉시 읽도록 함

05편에서 확인했듯 useActionState로 만든 비제어 입력은 제출 실패 후에도 DOM에 입력한 값이 남아 있습니다. 그래서 값을 따로 되돌려줄 필요 없이 에러 객체만 상태에 추가하면 됩니다.

실습

1. 파일 열기

src/pages/NewBookPage.jsx를 엽니다(05편에서 만든 파일).

2. 코드 작성

검증 함수를 파일 위쪽에 추가하고, submitNewBook과 JSX를 아래처럼 바꿉니다. 파일 전체를 아래 내용으로 교체합니다.

// src/pages/NewBookPage.jsx import { useActionState } from 'react'; import { useNavigate } from 'react-router'; import { useLocalStorage } from '../hooks/useLocalStorage.js'; const COVER_COLORS = ['#2563eb', '#16a34a', '#f59e0b', '#db2777', '#7c3aed', '#0891b2']; const initialState = { errors: {} }; function validateBookInput({ title, author, pages, books }) { const errors = {}; if (title === '') { errors.title = '제목을 입력합니다.'; } else if (books.some((book) => book.title.trim().toLowerCase() === title.toLowerCase())) { errors.title = '이미 등록된 책 제목입니다.'; } if (author === '') { errors.author = '저자를 입력합니다.'; } if (pages !== '' && (Number.isNaN(Number(pages)) || Number(pages) < 0)) { errors.pages = '쪽수는 0 이상의 숫자로 입력합니다.'; } return errors; } function NewBookPage() { const navigate = useNavigate(); const [books, setBooks] = useLocalStorage('bookshelf-books', []); async function submitNewBook(prevState, formData) { const title = formData.get('title').trim(); const author = formData.get('author').trim(); const pages = formData.get('pages').trim(); const errors = validateBookInput({ title, author, pages, books }); if (Object.keys(errors).length > 0) { return { errors }; } const nextId = books.length === 0 ? 1 : Math.max(...books.map((book) => book.id)) + 1; setBooks([ ...books, { id: nextId, title, author, status: formData.get('status'), rating: 0, pages: pages === '' ? 0 : Number(pages), coverColor: COVER_COLORS[Math.floor(Math.random() * COVER_COLORS.length)], startedAt: null, finishedAt: null, memo: formData.get('memo').trim(), }, ]); navigate('/'); return { errors: {} }; } const [state, formAction, isPending] = useActionState(submitNewBook, initialState); const { errors } = state; return ( <section className="new-book-page"> <h2>책 추가</h2> <form action={formAction} noValidate> <label htmlFor="new-book-title">제목</label> <input id="new-book-title" name="title" type="text" placeholder="예: 클린 코드" aria-invalid={Boolean(errors.title)} aria-describedby={errors.title ? 'new-book-title-error' : undefined} /> {errors.title && ( <p id="new-book-title-error" className="field-error" role="alert"> {errors.title} </p> )} <label htmlFor="new-book-author">저자</label> <input id="new-book-author" name="author" type="text" placeholder="예: 로버트 씨 마틴" aria-invalid={Boolean(errors.author)} aria-describedby={errors.author ? 'new-book-author-error' : undefined} /> {errors.author && ( <p id="new-book-author-error" className="field-error" role="alert"> {errors.author} </p> )} <label htmlFor="new-book-pages">쪽수</label> <input id="new-book-pages" name="pages" type="number" min="0" placeholder="예: 584" aria-invalid={Boolean(errors.pages)} aria-describedby={errors.pages ? 'new-book-pages-error' : undefined} /> {errors.pages && ( <p id="new-book-pages-error" className="field-error" role="alert"> {errors.pages} </p> )} <label htmlFor="new-book-status">상태</label> <select id="new-book-status" name="status" defaultValue="wish"> <option value="wish">읽고 싶어요</option> <option value="reading">읽는 중</option> <option value="done">다 읽음</option> </select> <label htmlFor="new-book-memo">메모</label> <textarea id="new-book-memo" name="memo" placeholder="선택 입력" /> <button type="submit" disabled={isPending}> {isPending ? '추가하는 중...' : '추가'} </button> </form> </section> ); } export default NewBookPage;

formnoValidate를 추가해 브라우저 기본 검증 풍선말 대신 우리가 만든 에러 메시지만 보이게 했습니다. 05편에서 쓰던 state.error(문자열 하나)는 state.errors(객체)로 바뀌었고, 06편에서 만든 useLocalStorage가 반환하는 books 배열을 검증 함수에 그대로 넘겨 중복 제목을 확인합니다.

3. 실행

npm run dev

4. 확인

  • 제목만 비운 채 제출하면 제목 입력 바로 아래에만 에러 문구가 보입니다. 저자 입력 아래에는 아무것도 나타나지 않습니다.
  • 이미 있는 책 제목(예: “클린 코드”)을 그대로 입력하고 제출하면 “이미 등록된 책 제목입니다.”가 보입니다.
  • 쪽수에 -5를 입력하고 제출하면 쪽수 아래에만 에러가 보입니다.
  • 개발자 도구에서 제목 입력창 요소를 확인하면 에러가 있을 때 aria-invalid="true"aria-describedby="new-book-title-error"가 붙어 있습니다.
  • 모든 값을 올바르게 채우면 목록 페이지로 이동하고 에러가 남아 있지 않습니다.

직접 해보기

  1. 메모 필드에 최대 200자 제한을 추가하고, 초과하면 에러를 보여주세요.
  2. 저자 이름에 숫자가 포함되면 막는 검증을 추가해 보세요(힌트: 정규식 /\d/).

풀이 보기

function validateBookInput({ title, author, pages, memo }) { const errors = {}; // ...기존 검증 유지 if (memo.length > 200) { errors.memo = '메모는 200자 이하로 입력합니다.'; } if (/\d/.test(author)) { errors.author = '저자 이름에는 숫자를 넣을 수 없습니다.'; } return errors; }

submitNewBook에서 formData.get('memo').trim() 값을 validateBookInput에 함께 넘기도록 호출부도 바꿔야 합니다.

자주 하는 실수

증상원인고치는 법
에러가 모든 필드 아래에 똑같이 나타남errors를 문자열 하나로 관리필드 이름을 키로 하는 객체({ title: ..., author: ... })로 관리
스크린 리더가 에러를 안 읽어줌에러 문단에 role="alert"이 없음에러 메시지 요소에 role="alert" 추가
유효한 값인데도 에러가 안 사라짐이전 렌더링의 errors 객체를 그대로 참조submitNewBook이 매번 새 errors 객체를 만들어 반환하는지 확인
브라우저 기본 풍선말과 커스텀 에러가 동시에 뜸<form>noValidate를 안 붙임noValidate 속성 추가

확인 문제

문제 14지선다
에러를 필드별 객체(errors.title, errors.author 등)로 관리하는 이유는
문제 24지선다
aria-describedby가 하는 역할은
문제 34지선다
form 요소에 noValidate를 추가하는 이유는

참고 자료

Last updated on