React Custom hook을 잘 사용해보자

박종대·2022년 11월 2일
2

React

목록 보기
2/2

Class형 컴포넌트 VS 함수형 컴포넌트

React에서 컴포넌트를 선언하는 방법은 Class 방식과 함수형 방식 두 가지가 있습니다.

Class형 컴포넌트

먼저 Class형 컴포넌트의 작성 방식에 대해 알아보겠습니다.

import { Component } from "react";

// props type
interface ClassExampleProps {
  text: string;
}

// state type
type ClassExampleState = {
  count: number;
};

// class component
class ClassExample extends Component<ClassExampleProps> {
  // state 선언 ver.javascript
  constructor(props: ClassExampleProps) {
    super(props);
    this.state = {
      count: 0,
    };
  }

  // state 선언 ver.typescript
  state: ClassExampleState = {
    count: 0,
  };

  componentDidMount() {
    // 컴포넌트가 DOM Tree에 추가된 직후
  }
  componentDidUpdate() {
    // state 갱신이 이루어진 직후
  }
  componentWillUnmount() {
    // 컴포넌트가 DOM Tree에서 제거되기 직전
  }

  // render 함수
  render() {
    const { text } = this.props;
    return (
      <div>
        <button
          type="button"
          onClick={() => this.setState({ count: this.state.count + 1 })}
        ></button>
        {`${text} : ${this.state.count}`}
      </div>
    );
  }
}

해당 코드를 봤을 때 class형 컴포넌트의 문제점을 확인할 수 있습니다.

먼저 기본적으로 반드시 작성해줘야 하는 보일러 플레이트 코드가 존재합니다. 반드시 React의 Component 클래스를 상속받아야 하고 생성자에서 super(props)를 호출 해야 합니다. 개발자 입장에서 이런 코드를 직접 작성해줘야 하는 것이 찝찝합니다.

또한 state나 props 등의 내부 속성에 접근 시 this로 접근해야만 합니다. this를 계속 붙여줘야 한다는 것 자체도 불편하지만 자바스크립트에서 this는 조금 특별합니다. 런타임에 this의 scope가 결정되기 때문에 때에 따라 this가 가리키는 것이 달라질 수 있어서 binding을 해 줘야할 수 있습니다.

마지막으로 a 상태가 갱신 됐을 때, b 상태가 갱신 됐을 때 각기 다른 동작을 하고 싶은 경우에 componentDidUpdate 생명 주기 메소드 하나에 로직을 전부 넣어야 하기 때문에 코드가 복잡해질 수도 있습니다.

👉 보일러 플레이트 코드의 존재 👉 내부 속성 접근 시 this 사용 👉 생명 주기 메소드나 state 관리 로직의 재사용이 어려움

React에서 말하는 클래스형 컴포넌트의 문제점

함수형 컴포넌트

이러한 문제점들을 해결하기 위해 함수형 컴포넌트가 등장 했습니다.

export const FunctionalComponent = () => {
    return (<div>functional component</div>)
}

컴포넌트를 함수로 작성하면서 많은 보일러 플레이트 코드가 사라진 것 같지만 여전히 state나 lifecycle 메소드는 활용할 수 없다는 문제점이 존재했습니다.

Hook의 등장

이러한 문제점을 해결하기 위해 React 16.8버전부터 Hook이라는 개념이 도입되기 시작합니다. React 공식 문서에서는 Hook을 다음과 같이 언급합니다.

Hook은 기존 Class형 컴포넌트 바탕의 코드를 작성할 필요 없이 상태 값과 React의 기능을 활용할 수 있다.

해당 내용을 바탕으로 제가 재해석한 결과는 다음과 같습니다.

Hook은 함수형 컴포넌트에서 React state와 life cycle 기능을 연동할 수 있게 해주는 함수이다.

함수형 컴포넌트에서도 이제 state와 life cycle 로직을 사용할 수 있게 된 것입니다. 그럼 앞서 봤던 class형 컴포넌트 코드를 함수형 컴포넌트로 변경해 보겠습니다.

import { useEffect, useState } from "react";

// props type
interface FunctionalComponentProps {
    text: string;
}

// component
export const FunctionalComponent: React.FC<FunctionalComponentProps> = props => {
    const [count, setCount] = useState<number>(0); // set State
    const { text } = props;
    
    useEffect(() => {
        // componentDidMount
        // componentDidUpdate

        return () => {
            // componentWillUnmount
        }
    }, []);

    return (<div>
        <button type="button" onClick={() => setCount(prev => prev + 1)}>test</button>
        {`${text} : ${count}`}
    </div>);
}

코드가 엄청나게 줄어든 것을 확인할 수 있습니다. 차이점을 살펴보자면 state를 선언할 때 useState라는 React에서 기본적으로 제공하는 훅을 사용하여 선언 합니다. 이제는 constructor에서 setting 해 줄 필요가 없어졌습니다.

또 state나 내부 속성에 접근할 때 this를 사용하지 않아도 됩니다.

마지막으로 A 상태가 변할 때, B 상태가 변할 때 다른 로직을 적용하고 싶다면 useEffect의 두 번째 인자의 리스트에 A를 넘겨준 useEffect, B를 넘겨준 useEffect를 두 번 작성 해주면 됩니다.

👉 보일러 플레이트 코드 삭제 👉 내부 속성 접근 시 this 미사용 👉 생명 주기 메소드나 state 로직 재사용 가능

Custom Hook

useState나 useEffect와 같이 React에서 제공하는 기본 훅이나 useReducer와 같이 라이브러리에서 제공하는 여러가지 훅들이 존재한다면 개발자가 직접 hook을 만들 수도 있습니다.

Hook의 규칙

직접 hook을 만들기 위해서 먼저 hook의 원칙부터 알아야 겠습니다. hook은 반드시 컴포넌트 최상단에서 실행되어야 하고 React 함수 내에서만 호출 되어야 합니다.

  • 조건문, 반복문 등에서 호출되면 안되고 컴포넌트 최상단에서만 호출되어야 함.
  • React 함수 내에서만 호출 되어야 함.

사실 hook은 함수이기 때문에 이러한 원칙을 위배한다고 해서 에러를 띄우지는 않습니다. 하지만 eslint에 이것이 hook이라는 것을 알려서 에러를 띄우도록 할 수는 있습니다.

hook의 접두어는 반드시 use이어야 합니다. 이렇게 해야만 React 기본 eslint가 hook의 원칙을 위배하는지 체크 해줍니다.

use 접두어가 붙지 않았기 때문에 makeTest는 단순 자바스크립트 함수입니다. 그런데 hook이 아닌 자바스크립트 함수에서 useState 훅을 호출하고 있습니다. 이는 React 함수 내에서만 호출 되어야 한다는 hook의 원칙에 위배됩니다. 따라서 eslint가 동작하여 에러를 띄웁니다. 에러 메세지는 다음과 같습니다.

React Hook "useState" is called in function "makeTest" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use".

함수형 컴포넌트도 아니고(함수명 앞글자가 소문자이기 때문) custom hook도 아닌데 useState 훅을 호출하고 있다고 경고하는 것입니다. 만약 hook을 만드려고 한 것이라면 앞에 접두어로 use를 붙이라고 말합니다.

이제 eslint가 useTest를 커스텀 훅으로 인식하여 에러를 보여주지 않습니다.

분기문에서 makeTest를 호출하는 것은 아무 문제 없습니다. 단순 javascript 함수니까요. 하지만 커스텀 훅인 useTest는 훅이기 때문에 단순 javascript 함수에서 호출할 수 없기 때문에 에러를 띄웁니다.

Custom Hook의 사용

Custom Hook의 기본 목적은 encapsulation입니다. 반복되는 state나 life cycle 로직을 공통화하고 사용자가 모르게 한다는 것입니다. 말로만으로는 쉽게 와닿지 않을 수 있습니다. 간단한 예시 몇가지를 살펴보겠습니다.

  1. ui 로직과 state 관리 로직을 분리 할 수 있다.

    제가 ui/ux 팀과 협업하며 style 작업을 할 때 처음부터 ui 로직과 state 관리 로직을 분리 해서 개발 했다면 어땠을까 하는 생각을 많이 했습니다.

예를 들어 ux 팀에서 처음 전달해준 시안이 다음과 같다고 가정하겠습니다.

시안에 따라 다음과 같이 작업 하겠습니다.

import { useState } from "react"

export interface StudioInputProps {
    label: string;
}

export const StudioInput: React.FC<StudioInputProps> = props => {
    const { label } = props;
    const [text, setText] = useState<string>();

		// input 유효성 체크 로직
		// debounce 걸기

    return (
		<>
        <div>{label}</div>
        <input value={text} onChange={e => setText(e.target.value)} />
    </>)
}

그런데 작업 진행 중 추가적인 input 시안이 요청왔다고 가정하겠습니다.

새로운 input 컴포넌트를 개발해야겠습니다. 그런데 input text state 관리 로직이나 debounce를 사용하여 유효성 체크하는 로직까지 새로 작성하거나 StudioInput에 있는 코드를 복사 붙여넣기 하게 될 것 같습니다. 개발하는 입장에서 당연히 찝찝한 기분이 들어야 합니다. input과 관련된 state 로직을 바깥으로 빼서 ui 로직과 분리하고 싶습니다. 해당 방법 중 하나가 바로 Custom hook입니다.

// hooks
export const useInput = () => {
    const [text, setText] = useState<string>();

    // 유효성 체크 로직

    return {
        value: text,
        onChange: (e: React.ChangeEvent<HTMLInputElement>) => setText(e.target.value)
        // 유효성 체크 결과
    }
}

다음과 같이 text state 관리와 유효성 체크 로직이 들어간 Custom hook을 개발했다고 가정하면

export const StudioInput: React.FC<StudioInputProps> = (props) => {
  const { label } = props;
  const { value, onChange } = useInput();

  return (
    <>
      <div>{label}</div>
      <input value={value} onChange={onChange} />
    </>
  );
};

사용처에서는 다음과 같이 사용할 수 있습니다. 이제 새로운 Input 컴포넌트를 개발한다 해도 ui 로직만 다시 짜야할 뿐 text state 로직은 다시 짤 필요가 없어 졌습니다. useInput 훅만 사용한다면요.

  1. Encapsulation

Custom hook을 사용하면 사용자가 hook 내부의 동작에 대해서는 알 필요가 없습니다. 그저 해당 hook을 사용하기만 하면 됩니다. hook에 동작이 encapsulate 되어 있기 때문입니다.

사내 제품 개발 중 success나 error 메세지를 띄울 때 Custom hook을 사용하게 되었습니다.

import { useState } from 'react';

import {
  StudioErrorToast,
  StudioInfoToast,
  StudioSuccessToast,
} from '../../component/toast/StudioAlertToast';

// toast type
export type ToastType = 'success' | 'error' | 'info' | 'none';

// useToast custom hooks
export const useToast = () => {
  const [type, setType] = useState<ToastType>('none'); // toast type
  const [message, setMessage] = useState<string>(); // message

  const handleClose = () => setType('none'); // close toast handler

	// set toast type and message
  const setToastType = (toastType: ToastType, msg: string) => {
    setType(toastType);
    setMessage(msg);
  };

  switch (type) {
    case 'success':
      return {
        toast: (
          <StudioSuccessToast open={type === 'success'} onClose={handleClose} message={message} />
        ),
        setToastType,
      };
    case 'error':
      return {
        toast: <StudioErrorToast open={type === 'error'} onClose={handleClose} message={message} />,
        setToastType,
      };
    case 'info':
      return {
        toast: <StudioInfoToast open={type === 'info'} onClose={handleClose} message={message} />,
        setToastType,
      };
    default:
      return { toast: null, setToastType };
  }
};

type state에 따라 어떤 type의 toast를 return 할 지 결정해주고 setToastType 메소드를 통해 type과 message를 설정할 수 있도록 합니다. 해당 hook을 사용하면 사용하는 컴포넌트에서는 다음과 같이 사용하게 됩니다.

	try {
	// 쿼리 실행
      const response = await SQLExecuteCopy(activeCurrentConnectionId, query);
	// 성공 시 success toast
	  setToastType('success', SQL_EXECUTE_SUCCESS_MSG);

      return response.data.resultSet.map((result: any) => result.SCRIPT);
    } catch (e: any) {
	// 에러 발생 시 error toast
      setToastType('error', SQL_EXECUTE_ERROR_MSG);
    }

이제 setToastType 메소드 하나만으로 원하는 타입의 toast를 띄울 수 있게 되었습니다. 내부 로직은 useToast 내부에 encapsulation 되어 있으니 사용할 때 알 필요가 없습니다.

주의 해야 할 점

custom hook의 parameter가 custom hook 내부 state의 초깃값으로 설정하는 경우에 주의해야 합니다. 만약 parameter가 변경된다고 해도 내부 state는 변경되지 않기 때문입니다. 말로는 조금 어렵습니다.

지금부터는 라인 기술 블로그에서 읽은 예시를 조금 변형해서 사용하도록 하겠습니다.

React 컴포넌트를 커스텀 훅으로 제공하기 - 라인 기술블로그

해당 글에서는 checkbox의 list가 존재하고 해당 checkbox가 모두 check 상태가 되면 ‘다음’ 버튼이 활성화되는 시나리오를 설명하고 있습니다.

먼저 checkbox list와 해당 checkbox 각각의 label들을 입력 받아 출력하는 Checks라는 컴포넌트를 작성합니다.

export interface ChecksProps {
  checkList: boolean[];
  labels: string[];
  onCheck: (index: number) => void;
}

export const Checks: React.FC<ChecksProps> = props => {
  const { checkList, labels, onCheck } = props;

	// 입력받은 labels로 check box 생성
  return (
    <ul>
      {labels.map((label, idx) => (
        <li key={idx}>
          <label>
            <input
              type="checkbox"
              checked={checkList[idx]}
              onClick={() => onCheck(idx)}
            />
            {label}
          </label>
        </li>
      ))}
    </ul>
  );
};

해당 글에서는 isAllChecked를 구하는 로직과 label을 입력받아 Checks 컴포넌트를 출력하는 로직을 encapsulation 한 useChecks라는 커스텀 훅을 만들었습니다.

export interface UseChecksResult = [boolean, () => JSX.Element]
 
// custom hook
export const useChecks = (labels: readonly string[]): UseChecksResult => {
  // check list
  const [checkList, setCheckList] = useState(() => labels.map(() => false))
 
	// check event handler
  const handleCheckClick = (index: number) => {
    setCheckList((checks) => checks.map((c, i) => (i === index ? !c : c)))
  }
 
	// all checked state
  const isAllChecked = checkList.every((x) => x)
 
	// render check boxes
  const renderChecks = () => (
    <Checks checkList={checkList} labels={labels} onCheck={handleCheckClick} />
  )
 
  return [isAllChecked, renderChecks]
}

반복되는 isAllchecked 로직과 labels와 checkList는 매핑되니까 해당 관리 로직도 커스텀 훅으로 뺀 것이 적절해 보입니다. 하지만 해당 글에서는 엉성하고 문제점이 존재한다고 말하고 있습니다.

한 가지 주의할 점은, 여기서 샘플로 제시한 useChecks가 조금 엉성하게 구현됐다는 점입니다. 구체적으로 말씀드리자면, 현재 샘플은 나중에 입력 labels가 변하면 대응할 수 없습니다.

입력 labels가 변하면 대응할 수 없다 말하고 있습니다. 만약 런타임에 labels가 변경될 수 있는 프로그램이라고 한다면 useChecks의 parameter인 labels가 변경되긴 하지만 해당 labels를 초깃값 설정에 이용한 checkList state는 변하지 않습니다. 컴포넌트가 리렌더링 된다고 해서 state 값이 초깃값으로 돌아가지는 않는 것처럼 말이죠.

물론 useEffect의 2번째 인자에 labels를 줘서 labels parameter가 바뀔 때마다 setCheckList를 호출해서 변경해줄 수 있습니다만 그렇게 깔끔해 보이지는 않습니다.

그래서 parameter로 받은 변수를 hook 내부의 state 초깃값으로 설정할 때에는 반드시 해당 parameter가 런타임에 변경될 일이 없는가를 세심하게 따져봐야 합니다.

마치며

Component를 분리하지 않고도 React의 state 로직이나 life cycle 로직을 밖으로 빼낼 수 있다는 점이 굉장히 흥미로웠습니다. React 개발 중 코드가 지저분해 보이고 state나 lifecycle 로직을 복사 붙여넣기 하고 있다면 한번쯤 고민해볼 만한 수단 중 하나인 것 같습니다.

profile
Frontend Developer

0개의 댓글