더 나은 사용자 경험을 위해 폼 만들기
개발자의 시각에서 form은 그리 단순하지 않다.
폼은 그 입력 때문에 넓고 다양한 상태를 나타낼 수 있기 때문에 굉장히 복잡할 수도 있다.
하나 이상의 인풋이 유효하지 않은 경우 | 모든 인풋이 유효한 경우 |
---|---|
- 폼과 입력 값이 유효하지 않다면, 특정한 입력 값에 대해 🌀에러 메시지를 출력하고 🌟문제가 되는 입력값을 강조해야 한다. - ❌ 입력값이 제출되거나 저장되지 않도록 해야 한다. | ✅ 입력된 값이 확실하게 제출되고 저장되게 해야 한다. |
심지어 서버로 리퀘스트(요청)를 보낸 후, 특정 값이 사용 가능한지 확인해야 하는 비동기 유효성 검사를 이용해야 하기 때문에 상태를 알 수 없을 수도 있다.
또한 폼 안에 있는 모든 입력 값에는 유효성이 있다.
각각의 입력 값의 상태가 모여서 전체 폼의 상태를 결정한다.
에러 메시지를 출력하고 유효하지 않은 입력 값을 강조하는 부분을 다루면 더 복잡해 진다.
사용자 입력에 대한 유효성 검증을 언제 하느냐에 따라 상황이 달라지기 때문이다.
폼이 제출될 때 | 인풋 포커스 잃을 때 | 키 입력시 마다 |
---|---|---|
사용자에게 경고하기 전에 값을 입력할 수 있음 | 사용자에게 경고하기 전에 값을 입력할 수 있음 | 키 칠때마다 유효성 검사되므로, 올바른 값을 입력하기 전에 사용자에게 경고해버림 |
불필요한 경고는 피할수 있지만, 피드백이 너무 늦음 | 이 방법은 사용자가 아무것도 입력하지 않은 폼에 유용함 | 잘못된 입력에만 적용하면 보다 직접적인 피드백 제공 가능 |
이렇게 다 장단점이 있다.
사용자 입력을 가져오는 방법에는 두 가지 방법이 있다.
import { useState } from "react";
const SimpleInput = (props) => {
const [userName, setUserName] = useState("");
const userNameChangeHandler = (event) => {
setUserName(event.target.value);
};
const formSubmitHandler = (event) => {
event.preventDefault();
console.log(userName);
setUserName("");
};
return (
<form onSubmit={formSubmitHandler}>
<div className="form-control">
<label htmlFor="name">Your Name</label>
<input
ref={nameInputRef}
type="text"
id="name"
onChange={userNameChangeHandler}
value={userName}
/>
</div>
<div className="form-actions">
<button>Submit</button>
</div>
</form>
);
};
export default SimpleInput;
키를 입력할 때마다 즉각적인 유효성을 검증이 필요한 경우에는 상태를 사용하는 것이 좋다.
import { useRef } from "react";
const SimpleInput = (props) => {
const nameInputRef = useRef();
const formSubmitHandler = (event) => {
event.preventDefault();
const enteredValue = nameInputRef.current.value;
console.log(enteredValue);
nameInputRef.current.value = "";
};
return (
<form onSubmit={formSubmitHandler}>
<div className="form-control">
<label htmlFor="name">Your Name</label>
<input
ref={nameInputRef}
type="text"
id="name"
/>
</div>
<div className="form-actions">
<button>Submit</button>
</div>
</form>
);
};
export default SimpleInput;
폼이 제출되었을 때 한 번만 값이 필요하다면 ref를 사용하는 것이 좋다.
그런데 이 방법은 돔을 직접 조작하기 때문에 권장하는 방법은 아니다.
//
import { useRef, useState } from "react";
const SimpleInput = (props) => {
// const [userName, setUserName] = useState("");
const nameInputRef = useRef();
// const userNameChangeHandler = (event) => {
// setUserName(event.target.value);
// };
const formSubmitHandler = (event) => {
event.preventDefault();
// console.log(userName);
// setUserName("");
const enteredValue = nameInputRef.current.value;
console.log(enteredValue);
nameInputRef.current.value = "";
};
return (
<form onSubmit={formSubmitHandler}>
<div className="form-control">
<label htmlFor="name">Your Name</label>
<input
ref={nameInputRef}
type="text"
id="name"
// onChange={userNameChangeHandler}
// value={userName}
/>
</div>
<div className="form-actions">
<button>Submit</button>
</div>
</form>
);
};
export default SimpleInput;
브라우저에서 검증
브라우저에서 하는 유효성 검증, 즉 클라이언트 사이드 유효성 검증은, 실제 웹 사이트나 웹 앱에서 브라우저에서 유효성 검증하는 방식은 사용자 경험 측면에서 빠른 반응을 할 수 있으므로 좋다.
서버에서 검증
그런데 브라우저에서 뿐만 아니라 서버에서도 입력 값들이 검증되어야 한다.
브라우저에 있는 코드는 사용자에 의해 편집될 수 있기 때문에, 이는 사용자 경험을 위한 장치일 뿐이다.
보안을 위해 서버에서도 검증이 필요하다.
import { useState } from "react";
const SimpleInput = (props) => {
//입력된 인풋 값 저장하는 상태
const [userName, setUserName] = useState("");
//사용자가 인풋을 건드렸는지
const [enteredNameTouched, setEnteredNameTouched] = useState(false);
//새로운 값 입력될 때 마다 이 전체 컴포넌트가 재실행되기 때문에
//가장 최신의 userName, enteredNameTouched 상태를 반영할 수 있다.
//여기엔 userName 확인한 값 저장
//이 조건식이 true면, 즉 입력값에 뭔가 들어가 있으면 유효한 값
const enteredNameIsValid = userName.trim() !== "";
//❌ 인풋 유효하지 않은 경우 = 입력창은 건드려졌지만(true), 값이 유효하지 않을 때(!false)
const nameInputIsInvalid = enteredNameTouched && !enteredNameIsValid;
const userNameChangeHandler = (event) => {
setUserName(event.target.value);
//setUserName 함수를 통해 전체 컴포넌트 리렌더링 됨
};
//바로바로 검증하기 위해 유효하지 않으면 블러처리
const nameInputBlurHandler = () => {
//인풋이 블러되는건 사용자가 인풋 건드렸기 때문에 true로
setEnteredNameTouched(true);
};
const formSubmitHandler = (event) => {
event.preventDefault();
setEnteredNameTouched(true);
//formSubmitHandler 함수는 컴포넌트가 리렌더링 될 때마다 재생성됨
//따라서 이 함수는 enteredNameIsValid의 최신값을 가져올 수 있으니 문제 없음
if (!enteredNameIsValid) {
return;
}
console.log(userName);
setUserName("");
//터치도 초기화 해줘서 인풋 안건드린걸로 바꾸기
setEnteredNameTouched(false);
};
//유효성 검증에 따라 스타일 변경
const nameInputClasses = nameInputIsInvalid
? "form-control invalid"
: "form-control";
return (
<form onSubmit={formSubmitHandler}>
<div className={nameInputClasses}>
<label htmlFor="name">Your Name</label>
<input
type="text"
id="name"
onChange={userNameChangeHandler}
value={userName}
onBlur={nameInputBlurHandler}
/>
{nameInputIsInvalid && (
<p className="error-text">Name must not be empty.</p>
)}
</div>
<div className="form-actions">
<button>Submit</button>
</div>
</form>
);
};
export default SimpleInput;
전체 form이 유효하기 위해서는 모든 input이 유효해야 한다.
import { useState } from "react";
const SimpleInput = (props) => {
const [userName, setUserName] = useState("");
const [enteredNameTouched, setEnteredNameTouched] = useState(false);
const enteredNameIsValid = userName.trim() !== "";
const nameInputIsInvalid = enteredNameTouched && !enteredNameIsValid;
//이렇게 하면 간결하게 코드 작성 가능
let formIsValid = false;
//전체 폼 폼 유효성 설정
//폼의 각 인풋에 대해서 설정하면 된다.
if (enteredNameIsValid) {
formIsValid = true;
}
//인풋이 name과 password 두 가지라면 아래처럼
// if (enteredNameIsValid && passwordIsValid) {
// formIsValid = true;
// }
const userNameChangeHandler = (event) => {
setUserName(event.target.value);
};
const nameInputBlurHandler = () => {
setEnteredNameTouched(true);
};
const formSubmitHandler = (event) => {
event.preventDefault();
setEnteredNameTouched(true);
if (!enteredNameIsValid) {
return;
}
console.log(userName);
setUserName("");
setEnteredNameTouched(false);
};
//유효성 검증에 따라 스타일 변경
const nameInputClasses = nameInputIsInvalid
? "form-control invalid"
: "form-control";
return (
<form onSubmit={formSubmitHandler}>
<div className={nameInputClasses}>
<label htmlFor="name">Your Name</label>
<input
type="text"
id="name"
onChange={userNameChangeHandler}
value={userName}
onBlur={nameInputBlurHandler}
/>
{nameInputIsInvalid && (
<p className="error-text">Name must not be empty.</p>
)}
</div>
<div className="form-actions">
<button disabled={!formIsValid}>Submit</button>
</div>
</form>
);
};
export default SimpleInput;
입력 값을 알맞게 검증해 내는 방식에 대한 연습이다.
이를 위해 상태를 알맞게 사용하여 연습해보자.
import { useState } from "react";
const SimpleInput = (props) => {
// name
const [userName, setUserName] = useState("");
const [enteredNameTouched, setEnteredNameTouched] = useState(false);
// name validation
const enteredNameIsValid = userName.trim() !== "";
const nameInputIsInvalid = enteredNameTouched && !enteredNameIsValid;
// email
const [userEmail, setUserEmail] = useState("");
const [enteredEmailTouched, setEnteredEmailTouched] = useState(false);
// email validation
const enteredEmailIsValid = userEmail.includes("@");
const emailInputIsInvalid = enteredEmailTouched && !enteredEmailIsValid;
// form
let formIsValid = false;
// form validation
if (enteredNameIsValid && enteredEmailIsValid) {
formIsValid = true;
}
// name
const userNameChangeHandler = (event) => {
setUserName(event.target.value);
};
const nameInputBlurHandler = () => {
setEnteredNameTouched(true);
};
// email
const emailChangeHandler = (event) => {
setUserEmail(event.target.value);
};
const emailInputBlurHandler = () => {
setEnteredEmailTouched(true);
};
// form
const formSubmitHandler = (event) => {
event.preventDefault();
setEnteredNameTouched(true);
setEnteredEmailTouched(true);
if (!enteredNameIsValid && !enteredNameIsValid) {
return;
}
console.log(userName, userEmail);
setUserName("");
setUserEmail("");
setEnteredNameTouched(false);
setEnteredEmailTouched(false);
};
// name: styles
const nameInputClasses = nameInputIsInvalid
? "form-control invalid"
: "form-control";
// email: styles
const emailInputClasses = emailInputIsInvalid
? "form-control invalid"
: "form-control";
return (
<form onSubmit={formSubmitHandler}>
<div className={nameInputClasses}>
<label htmlFor="name">Your Name</label>
<input
type="text"
id="name"
onChange={userNameChangeHandler}
value={userName}
onBlur={nameInputBlurHandler}
/>
{nameInputIsInvalid && (
<p className="error-text">Name must not be empty.</p>
)}
</div>
<div className={emailInputClasses}>
<label htmlFor="email">Your E-mail</label>
<input
type="email"
id="email"
onChange={emailChangeHandler}
value={userEmail}
onBlur={emailInputBlurHandler}
/>
{emailInputIsInvalid && (
<p className="error-text">Please enter a valid E-mail.</p>
)}
</div>
<div className="form-actions">
<button disabled={!formIsValid}>Submit</button>
</div>
</form>
);
};
export default SimpleInput;
인풋이 여러개이고 사용하는 로직이 같다면 커스텀 훅을 사용하면 좋다.
물론 커스텀 컴포넌트를 만들어도 되지만 커스텀 훅을 만들어 보자.
//인풋 값, 인풋 창 건드려 졌는지 다룸
//유연해야 하기 때문에 확실한 검증 로직은 외부에서 훅으로 전달되어야 함
import { useState } from "react";
const useInput = (validateValue) => {
// input
const [enteredValue, setEnteredValue] = useState("");
const [isTouched, setIsTouched] = useState(false);
// input validation
//🌟검증 로직은 훅이 사용되는 부분에서 결정헤야 커스텀 훅 유연하게 사용 가능: 훅 인자로 받기
const valueIsValid = validateValue(enteredValue);
// enteredName.trim() !== "";
// enteredEmail.includes("@"));
// input is invalid
const hasError = !valueIsValid && isTouched;
// value change handler
const valueChangeHandler = (event) => {
setEnteredValue(event.target.value);
};
//input blur handler
const inputBlurHandler = () => {
setIsTouched(true);
};
const resetState = () => {
setEnteredValue("");
setIsTouched(false);
};
return {
value: enteredValue,
isValid: valueIsValid,
hasError,
valueChangeHandler,
resetState,
inputBlurHandler,
};
};
export default useInput;
import useInput from "../hooks/use-input";
const SimpleInput = (props) => {
// name input
const {
value: enteredName,
isValid: enteredNameIsValid,
hasError: nameInputHasError,
valueChangeHandler: nameChangeHandler,
inputBlurHandler: nameBlurHandler,
resetState: resetNameInput,
} = useInput((enteredName) => enteredName.trim() !== "");
//value 이용한 인라인 함수를 훅에 전달하면, value에 대해 위 함수를 호출한 결과가 반환되므로
//밸리데이션 할 수 있음
// email input
const {
value: enteredEmail,
isValid: enteredEmailIsValid,
hasError: emailInputHasError,
valueChangeHandler: emailChangeHandler,
inputBlurHandler: emailBlurHandler,
resetState: resetEmailInput,
} = useInput((enteredEmail) => enteredEmail.includes("@"));
// form
let formIsValid = false;
// form validation
if (enteredNameIsValid && enteredEmailIsValid) {
formIsValid = true;
}
// form
const formSubmitHandler = (event) => {
event.preventDefault();
if (!enteredNameIsValid && !enteredEmailIsValid) {
return;
}
console.log(enteredName, enteredEmail);
resetNameInput();
resetEmailInput();
};
// name: styles
const nameInputClasses = nameInputHasError
? "form-control invalid"
: "form-control";
// email: styles
const emailInputClasses = emailInputHasError
? "form-control invalid"
: "form-control";
return (
<form onSubmit={formSubmitHandler}>
<div className={nameInputClasses}>
<label htmlFor="name">Your Name</label>
<input
type="text"
id="name"
onChange={nameChangeHandler}
value={enteredName}
onBlur={nameBlurHandler}
/>
{nameInputHasError && (
<p className="error-text">Name must not be empty.</p>
)}
</div>
<div className={emailInputClasses}>
<label htmlFor="email">Your E-mail</label>
<input
type="email"
id="email"
onChange={emailChangeHandler}
value={enteredEmail}
onBlur={emailBlurHandler}
/>
{emailInputHasError && (
<p className="error-text">Please enter a valid E-mail.</p>
)}
</div>
<div className="form-actions">
<button disabled={!formIsValid}>Submit</button>
</div>
</form>
);
};
export default SimpleInput;
굳이 복잡하지 않아서 하지 않아도 되지만 리듀서를 연습해 보자.
import { useReducer } from "react";
const initialInputState = { value: "", isTouched: false };
const inputStateReducer = (preState, action) => {
if (action.type === "INPUT") {
return { value: action.value, isTouched: preState.isTouched };
//isTouched는 true로 설정하지 않음: 키를 입력 하는 중에 입력을 마치는 것이 아니기 때문에
//따라서 그냥 이전 상태로 설정
}
if (action.type === "BLUR") {
return { value: preState.value, isTouched: true };
//value 값도 그냥 이전 값으로 하면 된다. 키 입력 마다 받고 있기 때문에
}
if (action.type === "RESET") {
return { value: "", isTouched: false };
}
return { value: "", isTouched: false };
};
const useBasicInput = (valueValidate) => {
const [inputState, dispatch] = useReducer(
inputStateReducer,
initialInputState
);
const valueChangeHandler = (event) => {
dispatch({ type: "INPUT", value: event.target.value });
};
const inputBlurHandler = () => {
dispatch({ type: "BLUR" });
};
const reset = () => {
dispatch({ type: "RESET" });
};
const isValueValid = valueValidate(inputState.value);
const hasError = !isValueValid && inputState.isTouched;
return {
enteredValue: inputState.value,
isValueValid,
hasError,
valueChangeHandler,
inputBlurHandler,
reset,
};
};
export default useBasicInput;