"use client";
import cn from "@ui/src/utils/cn";
import { InputHTMLAttributes, useState, ChangeEvent, useEffect, forwardRef } from "react";
import { UseFormRegisterReturn } from "react-hook-form";
import ErrorMessage from "../ErrorMessage";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
id: string;
placeholder: string;
type?: "text" | "password" | "number" | "email";
isError?: boolean;
errorMessage?: string;
className?: string;
register?: UseFormRegisterReturn;
value?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ placeholder, id, type = "text", isError, errorMessage, className, value = "", register, ...args }, ref) => {
const { onChange = () => {}, onBlur = () => {}, disabled, name, ref: regRef } = register || {};
const [hasValue, setHasValue] = useState(!!value);
useEffect(() => {
setHasValue(!!value);
}, [value]);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setHasValue(!!e.target.value);
onChange(e);
};
return (
<div className={cn("group relative", className)}>
<input
id={id}
defaultValue={value}
name={name || id}
type={type}
className={cn(
"transition-linear border-custom-black/40 hover:bg-custom-black/5 peer w-full rounded-lg border border-solid p-14 placeholder-transparent focus:hover:bg-purple-700/5",
{
"border-error focus:hover:bg-custom-black/5": isError,
"focus:border-purple-400": !isError,
},
)}
style={{ outline: "none" }}
placeholder={placeholder}
onBlur={onBlur}
onChange={handleChange}
disabled={disabled}
ref={ref || regRef}
{...args}
/>
<label
htmlFor={id}
className={cn(
"bottom-39 transition-linear text-custom-black/80 peer-focus:-translate-y-27 peer-focus:!text-13 relative left-16 z-10 bg-transparent p-0 leading-none peer-placeholder-shown:translate-y-0 peer-focus:bg-white peer-focus:px-3",
{
"peer-focus:bg-transparent peer-focus:text-purple-400": !isError,
"peer-focus:text-error peer-focus:bg-transparent": isError,
"-translate-y-27 !text-13 bg-transparent px-3": !isError && hasValue,
"-translate-y-27 !text-13 text-error bg-transparent px-3": isError && hasValue,
},
)}
style={{ display: "inline-block" }}
>
<span className="relative z-10">{placeholder}</span>
<span
className={cn("absolute bottom-6 left-0 right-0 z-0 h-4 group-focus-within:bg-white", {
"bottom-6 bg-white": hasValue,
})}
/>
</label>
{isError && <ErrorMessage className="left-0" message={errorMessage} />}
</div>
);
},
);
export default Input;
이번에 처음으로 input을 공용 컴포넌트로 만들어보았는데, 프로젝트 기간이 너무 짧아 react-hook-form에 대한 공부를 별로 하지 않고 동작(?)만 하는 컴포넌트를 제작했다.
팀원들과 소통하며 내 input의 문제를 파악했는데,
1) 비제어 컴포넌트를 제어 컴포넌트처럼 만들었다.
2) react-hook-form과 맞지 않다.
라는 점이었다.
그래서 일단 예전에 배운 제어컴포넌트와 비제어컴포넌트에 대한 차이점도 기억이 잘 나지 않고, react-hook-form에 대한 공부를 해야겠다고 느껴 정리해보게 됐다.
제어 컴포넌트는 react-state로 직접 입력값을 제어한다.
// React state로 입력값을 직접 제어
function ControlledInput() {
const [value, setValue] = useState("");
return (
<input
value={value} // React state로 값을 제어
onChange={(e) => setValue(e.target.value)} // 변경사항을 state에 반영
/>
);
}
특징
// DOM이 입력값을 직접 관리
function UncontrolledInput() {
const inputRef = useRef();
const handleSubmit = () => {
console.log(inputRef.current.value); // 필요할 때만 값을 읽음
}
return (
<input
ref={inputRef} // ref를 통해 DOM에 직접 접근
defaultValue="기본값" // 초기값 설정
/>
);
}
특징
react-hook-form에서는 기본적으로 비제어 방식을 사용하여 성능을 최적화한다. 필요한 경우 Controller를 통해 제어 컴포넌트도 지원하며, DOM의 기본 동작을 활용하면서도 강력한 폼 관리 기능을 제공한다.
hook-form의 기본 동작 방식도 파악하지 못한채 사용했던 내 자신.. 반성해..!
그럼 내 애증의 input이 리액트 훅폼의 비제어 컴포넌트 방식을 따라가면서 성능 최적화 될 수 있게 하려면 어떻게 해야될까?
value를 따로 받아서 값이 있는지 없는지 체크하는 로직을 훅폼에 맞게 리팩토링하기
const Input = forwardRef<HTMLInputElement, InputProps>( ({ placeholder, id, type = "text", isError, errorMessage, className, value = "", register, ...args }, ref) => { const { onChange = () => {}, onBlur = () => {}, disabled, name, ref: regRef } = register || {}; const [hasValue, setHasValue] = useState(!!value);```
현재 프롭으로 value를 또 받고 있다.
register에서는 value를 직접적으로 받을 수 없어서 훅폼에서 감지하고 있는 value는 다른 방식으로 감지를 해야했다.
이에 대한 해결 방법은 두 가지로 나누어볼 수 있는데, 첫 번째로는 상위 컴포넌트에서 FormProvier에서 폼을 감싸주고, useFormContext로 Input 컴포넌트에서 폼상태에 접근하는 방식이다.
// input 사용처
import { useForm, FormProvider, useFormContext } from "react-hook-form";
function MyForm() {
const methods = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<Input id="inputFieldName" placeholder="Enter text" name="inputFieldName" />
<button type="submit">Submit</button>
</form>
</FormProvider>
);
}
// input
import { useFormContext } from "react-hook-form";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
id: string;
placeholder: string;
type?: "text" | "password" | "number" | "email";
isError?: boolean;
errorMessage?: string;
className?: string;
name: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ placeholder, id, type = "text", isError, errorMessage, className, name, ...args }, ref) => {
const { register, watch } = useFormContext();
const value = watch(name);
const [hasValue, setHasValue] = useState(!!value);
useEffect(() => {
setHasValue(!!value);
}, [value]);
이 방식의 장점은
사용하는 곳에서 FormProvider로 감싸면 폼 내부의 모든 컴포넌트가 동일한 form 객체를 공유하므로 구조가 간단해진다.
Context를 사용해서 register, watch, getValues 등에 접근이 쉽고 코드가 간결해진다.
watch와 getValues로 필드 값을 관리하므로 필요할 때만 상태가 업데이트된다.
그러나 폼 상태가 변경될 때 모든 하위 컴포넌트가 리렌더링 될 수도 있고, 모듈화가 어렵다는 단점이 있다.
두 번째 방법은 Controller 사용이다.
Controller는 개별 폼 필드의 상태를 react-hook-form과 연결할 때 사용되고, 제어 컴포넌트처럼 사용할 때 유용하다. Controller를 사용할 때는
import { useForm, Controller, FormProvider } from "react-hook-form";
function MyForm() {
const methods = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<Controller
name="inputFieldName"
control={methods.control}
render={({ field, fieldState }) => (
<Input
{...field}
id="inputFieldName"
placeholder="Enter text"
isError={!!fieldState.error}
errorMessage={fieldState.error?.message}
/>
)}
/>
<button type="submit">Submit</button>
</form>
</FormProvider>
);
}
// input
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
id: string;
placeholder: string;
type?: "text" | "password" | "number" | "email";
isError?: boolean;
errorMessage?: string;
className?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ placeholder, id, type = "text", isError, errorMessage, className, value, onChange, onBlur, ...args }, ref) => {
return (
Input 컴포넌트에서는 value와 onChange를 바로 받으므로, register 없이도 폼과 연결된다.
이 방법을 사용하는 장점은
Controller로 각 필드의 상태를 개별적으로 관리해서 리렌더링을 줄이고 성능을 최적화 할 수 있다.
폼 필드마다 독립적인 상태를 관리할 수 있으며, FormProvider로 감싸지 않아도 개별적으로 필요한 Input 컴포넌트를 사용할 수 있다.
성능 최적화: 각 Controller가 개별 필드의 상태와 검증을 담당하므로, 다른 필드의 상태 변화에 따라 리렌더링 되지 않는다.
단점으로는 폼의 각 필드마다 Controller를 추가해야하며, 코드가 길어질 수 있다는 점이었는데 Controller까지 Input 컴포넌트와 함께 공통 컴포넌트에서 관리하면 되지 않을까? 라는 생각이 들었다.
나는 성능최적화와 리렌더링을 줄이는 것이 제일 우선적이라고 생각했기 때문에 두 번째 방법인 Controller를 사용하기로 했다.
하지만... 원하는 방향대로 흘러가지 않았고, 코드가 복잡해지기만 하고 마음에 들지 않았다. 그래서 인턴 중에 잘하는 친구에게 물어보니 이렇게 하지 않아도 테일윈드의 'placeholder-shown' 이라는 속성이 있어서 placeholder가 없어졌을 때, 그리고 있을 때에 맞게 css를 조절할 수 있었다.
placeholder-shown (:placeholder-shown)
Style an input when the placeholder is shown using the placeholder-shown modifier:<input class="placeholder-shown:border-gray-500 ..." placeholder="you@example.com" />
이 속성과 group 속성을 결합하여 최종적으로는 기존에 있던 useEffect / useState를 지우고
"use client";
import cn from "@ui/src/utils/cn";
import { InputHTMLAttributes, forwardRef } from "react";
import { FieldError, UseFormRegisterReturn } from "react-hook-form";
import ErrorMessage from "../ErrorMessage";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
placeholder: string;
type?: "text" | "password" | "number" | "email";
className?: string;
register?: UseFormRegisterReturn;
error?: FieldError;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ placeholder, type = "text", className, error, register, ...args }, ref) => {
const { onChange, onBlur, disabled, name, ref: regRef } = register ?? {};
const isError = Boolean(error);
const errorMessage = error?.message;
return (
<div
className={cn("group relative", {
"pb-10": isError,
})}
>
<input
id={name}
name={name}
type={type}
className={cn(
"transition-linear border-custom-black/40 hover:bg-custom-black/5 peer w-full rounded-lg border border-solid p-14 placeholder-transparent focus:hover:bg-purple-700/5",
{
"border-error focus:hover:bg-custom-black/5": isError,
"focus:border-purple-400": !isError,
},
)}
style={{ outline: "none" }}
placeholder={placeholder}
onBlur={onBlur}
onChange={onChange}
disabled={disabled}
ref={ref ?? regRef}
{...args}
/>
<label
htmlFor={name}
className={cn(
"peer-[:not(:placeholder-shown)]:-translate-y-27 peer-[:not(:placeholder-shown)]:text-13 bottom-39 transition-linear text-custom-black/80 peer-focus:-translate-y-27 peer-focus:!text-13 relative left-16 z-10 bg-transparent p-0 leading-none peer-placeholder-shown:translate-y-0 peer-focus:bg-white peer-focus:px-3 peer-[:not(:placeholder-shown)]:px-3",
{
"peer-focus:bg-transparent peer-focus:text-purple-400": !isError,
"peer-focus:text-error peer-focus:bg-transparent": isError,
"text-error": isError,
},
)}
style={{ display: "inline-block" }}
>
<span className="relative z-10">{placeholder}</span>
<span
className={cn(
"absolute bottom-6 left-0 right-0 z-0 h-4",
"group-focus-within:bg-white",
"group-hover:bg-transparent",
"group-placeholder-shown:bg-transparent",
"group-[:not(:placeholder-shown)]:bg-white",
)}
/>
</label>
{isError ? <ErrorMessage className="left-6" message={errorMessage} /> : null}
</div>
);
},
);
Input.displayName = "Input";
export default Input;
css로만 처리하고 렌더링이 어떻게 되는지 리액트 개발자 도구로 확인해보니 기존에는 훅폼을 씀에도 불구하고 타자칠때마다 렌더링이 되었는데, 이제는 한 번 렌더링 후에는 타자칠 때 리렌더링 되지 않는 것을 확인할 수 있어 무척 뿌듯했다!