input 값이 있을 때 placeholder 위치 조절하는 기능 및 input 최적화하기

cho·2024년 11월 2일

💥 트러블 슈팅

목록 보기
2/11

💩 기존 코드

"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에 대한 공부를 해야겠다고 느껴 정리해보게 됐다.

🛠️ 제어 컴포넌트와 비제어 컴포넌트

제어 컴포넌트 (Controlled Components)

제어 컴포넌트는 react-state로 직접 입력값을 제어한다.

// React state로 입력값을 직접 제어
function ControlledInput() {
  const [value, setValue] = useState("");
  
  return (
    <input 
      value={value}  // React state로 값을 제어
      onChange={(e) => setValue(e.target.value)}  // 변경사항을 state에 반영
    />
  );
}

특징

  • React state가 '신뢰 가능한 단일 출처'가 된다.
  • 모든 state 변경이 React를 통해 이루어진다.
  • 입력값에 대한 즉각적인 검증이나 변형이 가능하다.
  • 값의 변경을 즉시 다른 UI 요소에 반영 가능하다.

비제어 컴포넌트 (Uncontrolled Components)

// DOM이 입력값을 직접 관리
function UncontrolledInput() {
  const inputRef = useRef();
  
  const handleSubmit = () => {
    console.log(inputRef.current.value);  // 필요할 때만 값을 읽음
  }
  
  return (
    <input 
      ref={inputRef}  // ref를 통해 DOM에 직접 접근
      defaultValue="기본값"  // 초기값 설정
    />
  );
}

특징

  • DOM 자체가 데이터를 관리한다.
  • ref를 통해 필요할 때만 값을 읽는다.
  • React state 업데이트가 없어 리렌더링이 적다.
  • 간단한 구현으로 성능상 이점이 있을 수 있다.

react-hook-form의 접근 방식

react-hook-form에서는 기본적으로 비제어 방식을 사용하여 성능을 최적화한다. 필요한 경우 Controller를 통해 제어 컴포넌트도 지원하며, DOM의 기본 동작을 활용하면서도 강력한 폼 관리 기능을 제공한다.

hook-form의 기본 동작 방식도 파악하지 못한채 사용했던 내 자신.. 반성해..!

💫 How to Refactor?

그럼 내 애증의 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]);

이 방식의 장점은

  1. 사용하는 곳에서 FormProvider로 감싸면 폼 내부의 모든 컴포넌트가 동일한 form 객체를 공유하므로 구조가 간단해진다.

  2. Context를 사용해서 register, watch, getValues 등에 접근이 쉽고 코드가 간결해진다.

  3. watch와 getValues로 필드 값을 관리하므로 필요할 때만 상태가 업데이트된다.

그러나 폼 상태가 변경될 때 모든 하위 컴포넌트가 리렌더링 될 수도 있고, 모듈화가 어렵다는 단점이 있다.

두 번째 방법은 Controller 사용이다.

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 없이도 폼과 연결된다.

이 방법을 사용하는 장점은

  1. Controller로 각 필드의 상태를 개별적으로 관리해서 리렌더링을 줄이고 성능을 최적화 할 수 있다.

  2. 폼 필드마다 독립적인 상태를 관리할 수 있으며, FormProvider로 감싸지 않아도 개별적으로 필요한 Input 컴포넌트를 사용할 수 있다.

  3. 성능 최적화: 각 Controller가 개별 필드의 상태와 검증을 담당하므로, 다른 필드의 상태 변화에 따라 리렌더링 되지 않는다.

단점으로는 폼의 각 필드마다 Controller를 추가해야하며, 코드가 길어질 수 있다는 점이었는데 Controller까지 Input 컴포넌트와 함께 공통 컴포넌트에서 관리하면 되지 않을까? 라는 생각이 들었다.

나는 성능최적화와 리렌더링을 줄이는 것이 제일 우선적이라고 생각했기 때문에 두 번째 방법인 Controller를 사용하기로 했다.

하지만... 원하는 방향대로 흘러가지 않았고, 코드가 복잡해지기만 하고 마음에 들지 않았다. 그래서 인턴 중에 잘하는 친구에게 물어보니 이렇게 하지 않아도 테일윈드의 'placeholder-shown' 이라는 속성이 있어서 placeholder가 없어졌을 때, 그리고 있을 때에 맞게 css를 조절할 수 있었다.

placeholder-shown

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로만 처리하고 렌더링이 어떻게 되는지 리액트 개발자 도구로 확인해보니 기존에는 훅폼을 씀에도 불구하고 타자칠때마다 렌더링이 되었는데, 이제는 한 번 렌더링 후에는 타자칠 때 리렌더링 되지 않는 것을 확인할 수 있어 무척 뿌듯했다!

0개의 댓글