React custom hook - useInput 정성들여 깎기

·

6 min read

개인프로젝트의 useInput 커스텀 훅을 보다가 좀 더 재사용 가능하게 고치고싶어졌다.

기존 코드

import { useState } from "react";
import { InputValue } from "../../../data/type/type";

export const useInput = (initialValue: InputValue) => {
  const [inputValue, setInputValue] = useState<InputValue>(initialValue);

  const onChangeHandler = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
    const { name, value } = event.target;
    setInputValue({ ...inputValue, [name]: value });
  };

  const onCheckHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, checked } = event.target;

    setInputValue({
      ...inputValue,
      [name]: checked,
    });
  };

  return {
    onChangeHandler,
    onCheckHandler,
    inputValue,
    setInputValue,  };
};

거슬리는 점

  • onChangeHandler와 onCheckHandler가 존재함

    • 체크박스 일 경우에도 onChangeHandler 하나로 처리해주고싶음
  • type InputValue 를 보니, 프로젝트 내에서만 사용하는 데이터의 타입이었음

    • 앞으로 만들 프로젝트에서도 사용하고싶어짐.

    • 현재 InputValue는 객체 형태인데, 단일 값을 가질 때에도 useInput을 사용하고 싶다고 생각함

1차 수정

import { useState } from "react";

type InputType = string | number | boolean;

interface InputValueProps {
  initialValue: InputType | { [key: string]: InputType };
}

export const useInput = ({ initialValue }: InputValueProps) => {
  const [inputValue, setInputValue] = useState(initialValue);
  const isObject = typeof inputValue === "object";

  const onChangeHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value, checked, type } = event.target;

    const newValue = type === "checkbox" ? checked : value;

    setInputValue(isObject ? { ...inputValue, [name]: newValue } : newValue);
  };

  return {
    onChangeHandler,
    inputValue,
    setInputValue,
  };
};
  • InputType을 number, string, boolean으로 정해주고, initialValue의 타입을 InputType 또는 문자열을 키로 하고 InputType을 값으로 갖는 객체로 지정해줌.

  • inputValue가 객체일 때(isObject === true), [name]:value 로 동적으로 키값을 변경해줌으로써 각 항목마다 조건처리를 해주지 않아도 됨

  • type이 checkbox일 때, checked(boolean)을 값으로 갖게 처리해줌으로 체크박스 대응

const { name, value, checked } = event.target;
const newValue = isObject ? {...inputValue, [name]:checked ?? value} : checked ?? value

처음에 이렇게 만들어놓고 checked가 존재할 때만 체크박스의 값을 처리해주도록 만들어서 와 ! 이제 됐다 ! 싶었는데(개인적으로 ?? 사용하는걸 좋아함.. 왠지 귀여워서….), 생각해보니 radio 에 대한 처리를 해줄 때에는 checked 값이 있지만, value를 가져와야 하므로 해당 로직으로는 원하는 결과를 얻을 수 없다고 생각함.

const { name, value, checked, type } = event.target;
const newValue = type === "checkbox" ? checked : value;

따라서 event.target에서 type까지 가져와서 checkbox일 때에만 checked 값을 넣도록 해줌.

자 이제 되지 않았나 ! 라고 생각했지만, 보통 프로젝트에서 form이든 뭐든 input을 처리해주는 것들의 타입을 따로 지정해준다는 걸 생각했을 때, 저것은 확장하기에 조금 적합하지 않다는 생각이 들었음. 그리고 타입스크립트가 기본적으로 제공해주는 것들을 조금 더 사용하고 싶어짐

수정이 필요해보이는 사항

  1. 상태 업데이트의 불변성 유지: **setInputValue**에서 상태를 업데이트할 때, **isObject**가 **true**일 때 객체가 복사되고 새로운 값이 추가됨. 그러나 **inputValue**는 항상 이전 상태를 참조하므로, 상태 업데이트에 대한 불변성을 유지할 수 없음. 객체를 업데이트할 때는 이전 상태를 기반으로 새로운 객체를 생성하여 새 상태로 설정해야 함. 이를 해결하기 위해 **setInputValue**를 사용할 때, 함수를 전달하여 이전 상태를 올바르게 업데이트하는 방법을 사용해야 함.

  2. 타입 가드 필요성: **isObject**는 **typeof**를 사용하여 **inputValue**의 타입이 객체인지 확인하고 있음. 그러나 TypeScript는 현재 **isObject**가 true일 때 **inputValue**의 타입을 객체로 인식하지 않을 수 있음. 따라서 좀 더 정확한 타입 가드를 사용하여 객체 타입을 보다 안정적으로 확인하는 것이 좋다고 판단함.

  3. 객체 업데이트 관련 주의사항: 객체를 업데이트할 때, spread 연산자를 사용하여 새로운 객체를 생성하는 방법은 기존의 객체를 변경하지 않고 새로운 객체를 만들어야 하는 경우에 유용하지만, 객체의 중첩된 상태를 올바르게 업데이트하고 관리하기 위해서는 깊은 복사(deep copy)나 더 복잡한 로직이 필요할 수 있음.

  4. React.ChangeEvent<HTMLInputElement>React.ChangeEvent<HTMLTextAreaElement>는 호환되지 않으므로 TextArea 인 경우에 대한 처리를 해줄 수 없음

  5. 수정에 대한 로직이라면 initialValue가 들어가야하는 것이 맞지만, 글 작성에 대한 것이라면 initialValue가 없어도 그에 맞춰서 만들어주면 좋을 것 같음 => 근데이건 어려울 것 같다.

2차 수정(현재까지는 최종본)

위의 수정이 필요해 보이는 사항에서 1, 2, 4번을 개선해보았다.

import { useState } from "react";

export const useInput = <T>(initialValue: T) => {
  //   const isObject = typeof initialValue === "object";

  const [inputValue, setInputValue] = useState<T>(initialValue);

  const isObject = (value: unknown): value is Record<string, unknown> =>
    typeof value === "object" && value !== null;
  // 다중 checkebox 처리 && 배열일 때 처리
  const handleCheckbox = (arr: T[], newItem: T): T[] =>
    arr.includes(newItem)
      ? arr.filter((item) => item !== newItem)
      : [...arr, newItem];

  const onChangeHandler = (
    event:
      | React.ChangeEvent<HTMLInputElement>
      | React.ChangeEvent<HTMLTextAreaElement>
  ) => {
    const { name, value, type } = event.target;
    const currentValue = inputValue[name];
    // input 요소의 경우 type이 checkbox인지 확인하여 checked 속성 가져오기

    const checked =
      type === "checkbox" && !value
        ? (event.target as HTMLInputElement).checked
        : undefined;

    const newValue = checked ?? value;

    // type = file 일 때 처리 해야함..

    setInputValue((prev) =>
      isObject(inputValue)
        ? Array.isArray(currentValue)
          ? {
              ...prev,
              [name]: handleCheckbox(currentValue, newValue as any),
            }
          : ({ ...prev, [name]: newValue } as T)
        : (newValue as T)
    );
  };

  return {
    onChangeHandler,
    inputValue,
    setInputValue,
  };
};

새롭게 알게된 것

Record<string, unknown>

TypeScript에서 사용되는 타입으로, 여기서 string 타입의 키와 어떤 값이든 가질 수 있는 타입.

**Record<K, T>**는 키 **K**와 값 **T**의 쌍으로 구성된 객체를 나타내는데, **string**은 키로서 문자열을 의미하며, **unknown**은 모든 타입을 나타냄.

즉, **Record<string, unknown>**은 문자열 키를 갖고 어떠한 타입의 값이라도 가질 수 있는 객체를 의미. 이것은 매우 유연한 객체 타입으로, 어떠한 형태의 프로퍼티도 포함할 수 있고 값의 타입이 무엇이든 될 수 있는 객체를 표현함.

is

TypeScript에서 사용되는 타입 가드(Type Guard)

타입 가드란 런타임에서 값의 타입을 체크하고 해당 값을 특정 타입으로 좁혀주는 역할. 이를 통해 TypeScript 컴파일러에게 코드 상의 타입 정보를 더 정확하게 전달하여 타입 안전성을 유지하도록 도움.

예) **value is Record<string, unknown>**의 형태에서 is 키워드는 value 변수가 Record<string, unknown> 타입인지 여부를 체크함. 이는 불리언(Boolean) 값을 반환하는 표현식.

**value is Record<string, unknown>**가 **true**라면, TypeScript는 해당 블록 내에서 **value**를 Record<string, unknown> 타입으로 간주함. 이후에 해당 블록 내에서는 **value**를 Record<string, unknown> 타입으로 사용할 수 있음. 이렇게 함으로써 TypeScript는 해당 블록 내에서 **value**의 타입을 좁혀서 더 정확한 타입 추론을 할 수 있게 됨.

unknown

TypeScript에서 사용되는 타입 중 하나로, 다른 모든 타입의 상위 타입.

JavaScript의 **any**와 유사하지만, 타입 안전성을 보장하기 위해 **any**보다 조금 더 엄격하게 동작함.

unknown 타입은 어떠한 값도 할당할 수 있지만, 해당 값의 타입 정보에 대해 아무 것도 알지 못함. 따라서 **unknown**으로 선언된 변수는 할당된 값의 타입 정보를 보존하면서도 안전한 방식으로 조작할 수 있도록 해줌.

**unknown**을 사용하는 변수는 다른 타입으로 할당하기 위해서는 명시적인 타입 체크나 타입 변환을 거쳐야 함.

이를 통해 개발자가 명시적으로 타입을 보장하고 안전한 코드를 작성할 수 있도록 도움.

let userInput: unknown;
let someValue: any;

userInput = 5;
someValue = userInput; // 'unknown'은 'any'에 할당할 수 있음

let stringLength: number;

// 'unknown'을 'string'으로 타입 단언(타입 변환)
if (typeof userInput === 'string') {
  stringLength = userInput.length; // 여기서 TypeScript는 'userInput'이 'string'임을 인지
}

// 'any'를 사용한 경우
let anyValue: any = 10;
let stringValue: string = anyValue; // 'any'는 'string'에 바로 할당 가능

사용법

interface UserInfo {
    name:string;
    age:number;
    address:string;
}
// 또는

type UserInfo = string

// initialValue 설정 어쩌구 저쩌구

const { onChangeHandler, inputValue, setInputValue } = useInput<UserInfo>(initialValue);

조금 더 개선하고 싶은데..

현재까지 만든 useInput은 객체안의 객체까지는 완벽하게 적용할수 없어 보인다.

deepCopy에 대한 유틸함수를 만들고싶은데, 조금더 공부해서 만들어보고 적용해야겠음. (그냥 immer 사용..?)

JSON.parse(JSON.stringify()), structuredClone()등이 있는걸 알지만 복잡한 구조의 객체일 때 뭔가 “완벽한” 깊은복사 함수를 만들어보고싶음. 그거 만들고나면 useInput의 3차 업뎃이 있을 예정…ㅎ…

2차수정하고나서 와 super sexy 하구만 하고 자아도취중이었는데 꼐속 부족한점이 보인다. . .(type=file 일때는 어떢하지, 값이 배열일땐 어떡하지..) 개선하면 되지~