React Custom hook을 어떻게 사용할까

Showmaker
17 min readNov 17, 2023

--

배경

React의 hook에 대한 등장 배경을 설명 드리기 위해 React의 Class형 컴포넌트와 함수형 컴포넌트에 대해 확인 해 보겠습니다. 처음 프론트엔드 개발에 입문하던 2021년 말 즈음에 함수형 컴포넌트가 본격적으로 도입 되기 시작 했었는데요, 함수형 컴포넌트는 기존 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 })}
>
Up
</button>
{`${text} : ${this.state.count}`}
</div>
);
}
}

레거시 코드나 먼 과거에 작성된 오픈 소스들을 분석 해 보시면 위와 같이 클래스형으로 작성된 컴포넌트들을 많이 보실 수 있을 겁니다. 보자마자 느끼시겠지만 여러가지 불편한 점들이 있습니다.

  • 보일러 플레이트 코드의 존재
    -
    React의 Component 클래스를 반드시 상속 받아야 하고, 생성자 코드와 super(props); 코드도 필수적으로 작성 되어야 합니다. render 함수도 따로 작성해 줘야 합니다. state나 props에 접근할 때는 this로 접근해야 합니다.
  • 개별 상태 변화에 따른 로직 작성의 가독성 저하
    -
    컴포넌트에 a 상태와 b 상태가 있다고 가정 해 봅시다. a 상태가 변경 되었을 때 특정 로직, b 상태가 변경 되었을 때 특정 로직을 실행하고 싶은 상황입니다. 클래스형 컴포넌트에서는 componentDidUpdate 생명주기 메소드 한 군데에 이 두 가지 로직을 추가해야 합니다. 동작에는 문제가 없겠지만 가독성 측면에서 비효율적임을 느낄 수 있을 것입니다.

리액트 공식 문서에서도 Hook의 등장 배경으로 Class형 컴포넌트에 어떤 문제들이 있었는지 제시하고 있습니다. 링크에 접속하셔서 꼭 한 번 읽어 보시기 바랍니다.

함수형 컴포넌트의 등장

Class형 컴포넌트의 문제점을 해결하기 위해 React 16.8 버전에서 부터 함수형 컴포넌트가 등장하기 시작합니다. 컴포넌트 함수 내부에서 상태 관리 로직들을 작성하고 함수의 반환 값으로 jsx 태그를 넘겨주면 됩니다.

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

그렇다면 함수형 컴포넌트에서는 상태 관리 로직을 어떻게 작성할 지, 상태 관리의 lifecycle을 어떻게 관리할 지에 대해 의문이 생깁니다. 기존 Class형 컴포넌트에서는 생명 주기 메소드를 제공하고 이를 Override 하는 방식으로 구현이 됐습니다.

Hook의 등장

React 공식 문서에서는 Hook을 다음과 같이 언급합니다.

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

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

Hook은 함수형 컴포넌트에서 React statelife 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라는 훅을 사용하여 선언 합니다. 이제는 constructor에서 세팅해 줄 필요가 없어졌습니다.

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

마지막으로 A 상태가 변할 때, B 상태가 변할 때 개별 로직을 작성하고 싶다면 변화 인자 리스트에 A를 넘겨준 useEffect 하나, B를 넘겨준 useEffect 하나를 각각 따로 작성 해주면 됩니다.

React Custom Hook

Custom hook의 작성

useState나 useEffect와 같은 것들을 Hook이라고 한다고 했습니다. 이것들은 React에서 기본적으로 제공 해주는 Hook입니다. React로 개발하게 되면 가장 많이 사용하게 되실 것들입니다. 이 Hook은 개발자가 직접 작성하여 새로운 Hook을 생성할 수도 있습니다. 이것을 Custom Hook이라고 명칭하고 있는 것입니다.

Hook도 결국에는 함수 하나이기 때문에 이것이 React의 Hook으로 동작하는 함수인 것이냐, 단순 일반 함수인 것이냐에 대한 구분이 필요합니다. 이를 위해React에서는 Custom hook을 작성하기 위한 규칙 몇가지를 정의합니다.

  1. 조건문, 반복문 등에서 호출될 수 없고 컴포넌트 최상단에서만 호출 가능하다.
  2. React 컴포넌트 함수 내에서만 호출 되어야 한다.
  3. 함수 이름의 접두어는 반드시 ‘use’ 로 지정해야 한다.
// just javascript function, not react hook
export const makeTest = () => {
const [A, setA] = useState<boolean>(false);
}

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

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를 사용하라고 말합니다. Hook 작성 규칙에 따라 프로그램을 수정하면 다음과 같습니다.

export const useTest = () => {
const [A, setA] = useState<boolean>(false);
return ({ A });
}

Custom hook의 사용

Custom hook의 등장 배경이나 Custom hook의 작성 방법도 알겠습니다. 그럼 왜 사용해야 하고 어떤 경우에 사용해야 할까 고민이 됩니다. Custom hook이 근본적으로 어떤 목적을 가지는지 생각해 보면 됩니다. 결론적으로 Custom hook은 React의 State와 Lifecycle 관리 로직을 공통화 할 수 있도록 도와줍니다. 쉽게 말해 컴포넌트 단에서의 복사/붙여넣기를 하고 있는 로직을 재사용할 수 있도록 도와주겠다는 의미입니다.

Modal을 띄운 다거나, 특정 태그를 disabled 하거나, Checkbox 상태를 변경할 때와 같이 수많은 경우에 boolean state를 두고 로직을 작성하신 경험이 있으실 겁니다. 작성하실 때 결국 반복적인 작업이라는 느낌이 오지 않으셨나요? boolean state 선언하고, open handler를 선언하고 close handler를 선언하는 것들이 결국 똑같은 작업이지 않나요? boolean state를 다루는 custom hook을 다음과 같이 작성할 수 있습니다.

function useBooleanState<T extends boolean | Record<string, boolean>>(
defaultValue: T
): T extends Record<infer Key, boolean>
? readonly [(key: Key) => boolean, (key: Key) => void, (key: Key) => void, (key: Key) => void]
: readonly [boolean, () => void, () => void, () => void] {
const isPrimitiveBoolType = typeof defaultValue === 'boolean';
const [value, setValue] = useState<T>(defaultValue);

const setTrue = useCallback<((key: string) => void) | (() => void)>(
isPrimitiveBoolType
? () => {
setValue(true as T);
}
: (key: string) => {
setValue(previous => {
const typeAsserted = previous as Record<string, boolean>;
return { ...typeAsserted, [key]: true } as T;
});
},
[]
);

const setFalse = useCallback(
isPrimitiveBoolType
? () => {
setValue(false as T);
}
: (key: string) => {
setValue(previous => {
const typeAsserted = previous as Record<string, boolean>;
return { ...typeAsserted, [key]: false } as T;
});
},
[]
) as ((key: string) => void) | (() => void);

const toggle = useCallback<((key: string) => void) | (() => void)>(
isPrimitiveBoolType
? () => {
setValue(previous => {
const typeAsserted = previous as boolean;
return !typeAsserted as T;
});
}
: (key: string) => {
setValue(previous => {
const typeAsserted = previous as Record<string, boolean>;
return { ...typeAsserted, [key]: !typeAsserted[key] } as T;
});
},
[]
);

const isTrue = isPrimitiveBoolType
? (value as boolean)
: (key: string) => (value as Record<string, boolean>)[key];

return [isTrue, setTrue, setFalse, toggle] as any;
}

사실 이러한 Custom hook의 사용은 Toss의 Slash Github에서 영감을 받아 인용되었습니다. 정말 많은 Custom hook의 사용 예시를 제시하고 있으므로 꼭 참고하셨으면 좋겠습니다. 개발자로서 반복된 코드를 복사/붙여넣기 하는건 최악의 경험이라는 측면에서 Custom hook은 정말 중요한 개념이니까요.

이외에도 Debounce, Throttle 기술을 적용하고 싶을 때에도 컴포넌트마다 개별적으로 로직을 작성하는 것이 아니라 Custom hook을 작성하여 해결할 수도 있습니다. 또한 전역 state를 구독할 때에도 Custom hook을 사용할 수 있습니다. Redux-toolkit 기준으로 전역 state를 컴포넌트에서 가져올 때 다음과 같이 작성합니다.

const { globalStateA, globalStateB } = useSelector(state => state.appReducer);

이러한 전역 state가 100개의 컴포넌트에서 사용되고 있다고 가정 해 봅시다. 이 때 globalStateA 라는 state 명이 globalStateC로 변경되었다고 가정 해 보면 불편함을 느낄 수 있을 겁니다. 100개의 컴포넌트 코드에 가서 일일이 수정해 줘야 합니다. 이를 방지하기 위해 전역 state를 구독하는 것 조차 Custom hook으로 Wrapping 하는 방식을 제안하기도 합니다.

export const useGlobalStateTest = () => {
const { globalStateA, globalStateB } = useSelector(state => state.appReducer);
return ({ globalStateA, globalStateB });
}

이제 state명이 변경 되어도 useGlobalStateTest Custom hook 코드만 변경하면 됩니다.

주의 할 점

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

지금부터는 Line Tech Blog에서 읽은 예시를 조금 변형해서 사용하도록 하겠습니다.

React 컴포넌트를 커스텀 훅으로 제공하기

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

Checkbox

먼저 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가 런타임에 변경될 일이 없는가를 세심하게 따져봐야 합니다.

결론

Custom hook은 컴포넌트를 따로 분리하지 않고도 React의 State 로직이나 Lifecycle 관리 로직을 외부로 분리 해 낼수 있다는 점에서 중요한 개념입니다. 처음 개념을 익힐 때에는 “그래서 언제 사용해야 되는건데?” 싶을 수 있습니다. 제가 글에서 제공한 예시가 도움이 되었으면 좋겠고 소개해드린 Toss Slash github를 꼭 한 번 참고하셨으면 좋겠습니다.

--

--

Showmaker

3년차 Web Frontend Developer 입니다. 필요 시 Backend 개발도 병행하고 있습니다.