Custom Hooks

커스텀 훅을 알기 전에 기본적으로 Hook의 사용 규칙에 대해 아는게 좋습니다.

  • 훅은 무조건 use로 시작해야 합니다.
  • 훅은 컴포넌트의 최상단에 위치해야 합니다.
  • 최상위 레벨에서만 훅을 호출해야 합니다.
    • 조건문이나 반복문 내부에서 Hook을 호출하면 안됩니다.
    • 조건부 return문(early return) 이후에 Hook을 호출하면 안됩니다.
    • 이벤트 핸들러에서 Hook을 호출하면 안됩니다.
    • useMemo, useReducer, useEffect에 전달된 함수 내부에서 Hook을 호출하면 안됩니다.
    • try/catch/finally 블록 내부에서 Hook을 호출하면 안됩니다.
  • 오직 React 컴포넌트 또는 커스텀 훅에서만 호출해야 합니다.

훅의 규칙 핵심 원리

React는 훅이 항상 동일한 순서로 호출된다고 가정하고, 각 훅의 상태를 내부적으로 인덱스로 관리합니다. 이런 상황에서 조건문이나 반복문 내부에서 호출한다면 그 순서가 바뀌게 되고, 상태와 이펙트가 꼬이며 에러가 발생할 수 있습니다. 그래서 순서가 보장되도록 조건문이나 반복문 내부에서 훅을 호출하면 에러가 발생하지 않지만, 의미가 없는 코드이기에 고려하지 않아도 됩니다. 

이런 이유로 훅은 컴포넌트의 최상단에 위치해야 하고 최상위 레벨에서 호출해야 하는 것입니다.

 

그리고 커스텀 훅은 state 자체를 공유하는 것이 아니라 state 저장 로직을 공유하도록 해야 합니다.

이는 커스텀 훅 구현 코드를 보면 이해하기 쉬울 거 같습니다.

 

커스텀 훅은 로직 재사용성을 극대화하는 React의 핵심 기능입니다. React 19에서도 해당 기능이 강화되어 깔끔하고 확장 가능한 코드 구조를 구축할 수 있습니다.

이러한 커스텀 훅은 상태를 공유하지 않고 로직만 재사용해야 하며, React 훅의 규칙을 따릅니다.

 

예시 코드를 작성하며 설명드리겠습니다.

<예시 코드 1>

import { useState } from 'react';

function ueFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  const handleChange = (e) => {
    setValue(e.target.value);
  }
  
  return {value, onChange: handleChange};
}

export default function From () {
  const nameProps = useFormInput('Join');
  const emailProps = useFormInput('JoinMain@naver.com');
  
  return (
    <form>
      <input {...nameProps} />
      <input {...emailProps} />
    </form>
  );
}

해당 코드는 input에 들어갈 상태와 이벤트 핸들러를 훅으로 묶어서 바로 input 태그에 props로 내려줄 수 있도록 객체를 반환하는 훅입니다.

import { useState } from 'react';

function ueFormInput(initialValue) {
  const [value, setValue] = useState(initialValue);
  const handleChange = (e) => {
    setValue(e.target.value);
  }
  
  return [value, handleChange];
}

export default function From () {
  const [name, handleNameChange] = useFormInput('Join');
  
  return (
    <form>
      <input value={name} onChange={handleNameChange} />
    </form>
  );
}

만약에 값을 return 값을 배열로 보낸다면 그 순서를 기억하고 원하는 변수를 정의해 사용할 수도 있습니다.

 

 

<예시 코드 2>

import { useState } from 'react';

export default fucntion Counter () {
  const [count, setCount] = useState(0);
  
  const handlePlusClick = () => setCount(count => count + 1)
  const handleMinusClick = () => setCount(count => count - 1)
  
  return (
    <div>
      <p>Count : {count}</p>
      <button onClick={handlePlusClick}>+ 버튼</button>
      <button onClick={handleMinusClick}>- 버튼</button>
    </div>
  )
}

그럼 지금까지 애용하던 Counter 컴포넌트에는 어떻게 적용할 수 있을까요?

useState와 이벤트 핸들러 함수를 묶어서 useCounter 훅을 구현해 봅시다.

 

import {useState} from 'react';

function useCounter (initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  increment = () => setCount(prev => prev + 1);
  decrement = () => setCount(prev => prev - 1);

  return {count, increment, decrement};
}

export default function Counter () {
  const { count, increment } = useConter(0);
  
  return (
    <div>
      <p>Count : {count}</p>
      <button onClick={increment}>+ 버튼</button>
    </div>
  );
}

useCounter 훅을 구현해 적용한 코드입니다.

하지만 모든 중복 로직을 커스텀 훅으로 만드는 것은 좋은 방법이 아닙니다. 중복 로직을 커스텀 훅으로 만들었을 때, 데이터 흐름을 명확하게 이해하는 데 도움이 되고, 목적을 전달하는 데 도움이 된다고 판단될 경우 커스텀 훅으로 구현하는 것이 좋습니다.

 

커스텀 Hook으로 로직 재사용하기 – React

The library for web and native user interfaces

ko.react.dev

 

 

실무에서는 어떻게 활용하는게 제일 좋을까요?? 

  • 기능을 직관적으로 표현하여 의도 명확성을 높이자 (ex: useLocalStorage, useDebounde)
  • 단일 책임 원칙을 지키자! 한 훅은 하나의 기능만 수행하도록 설계해야합니다.
  • TypeScript의 제네릭 타입을 활용해 타입을 지원하여 타입 안정성을 확보하자
  • 불필요한 리렌더링 방지를 위해 메모이제이션을 적극 활용하자

핵심적으로 이렇게 4가지를 잘 생각해서 커스텀 훅을 고려해야 합니다. 그 사례들에 대해 알아볼까요?

 

1. 로컬 스토리지를 연동한 useLocalStorage

function useLocalStorage (key, initialValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
  
  useEffect(() => {
    localStorage.setItem(key, JSON.stringfy(value));
  }, [key, value]);
  
  return [value, setValue];
}

 

2. 이전 상태 추적 usePrevious

function usePrevious (value) {
  const ref = useRef();
  
  useEffect(() => {
    ref.current = value;
  });
  
  return ref.current;
}

 

3, 디바운싱 적용 useDebounce

function useDebounce(value, delay) {
  const [debounced, setDebounced] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    
    return () => clearTimeout(timer);
  }, [value, delay]);
  
  return debouned;
}

 

이렇게 여러 작업 로직들을 커스텀 훅으로 묶어서 편리하게 사용할 수 있습니다.

 

추가적으로 필수적인 TypeScript를 이용해 구현하는 방법도 작성하고자 합니다. 주로 TypeScript 기반으로 개발을 해서 이번 React 포스팅 과정에서 중간중간에 TypeScript를 사용한 코드도 있습니다..ㅋㅋ 다 작성한 이후에 통일할려고 합니다. js 기반인 React 공식문서를 보며 정리해서 그런지 섞이게 되네요ㅠㅠ 어쨌든 타입 안정성을 보장하는 TyprScript를 이용한 방법도 커스텀 훅을 만드는 과정에서 꼭 설명해야겠다는 생각이 들어 작성합니다.

 

1. TypeScript useLocalStorage

import { useState, useEffect } from 'react';

function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
  
  useEffect(() => {
    localStorage.getItem(key, JSON.stringify(value));
  }, [key, value]);
  
  return [value, setValue] as const;
}

// 사용 예시
export default Example () {
  const [name, setName] = useLocalStorage<string>('name', '홍길동');
  return (
    <input value={name} onChange={e => setName(e.target.value)} />
  );
}

제네릭 타입을 이용해 값에 대한 타입을 정의할 수 있도록 했습니다. 그리고 return 값에 as const를 붙여 튜플 타입으로 반환하여 구조분해 할당 측면에서 안전해집니다.

 

2. TypeScript useDebounce

import {useState, useEffect} from 'react';

function useDebounce<T>(value: T, delay: number) {
  const [debounced, setDebounced] = useState<T>(value);
  
  useEffect(() => {
    const handler = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(handler);
  }, [value, delay]);
  
  return debounced;
}

export function Example () {
  const [text, setText] = useState('');
  const debouncedText = useDebounce(text, 500);
  
  return (
    <div>
      <input value={text} onChange={e => setText(e.tartget.value)} />
      <p>디바운스된 값: {debouncedText}</p>
    </div>
  );
}

 

3. TypeScript useToggle

import { useState, useCallback } from 'react';

type UseToggleReturn = [
  boolean,
  () => void,
  () => void,
  () => void
];


function useToggle(initialValue = false): UseToggleReturn {
  const [state, setState] = useState<boolean>(initialValue);
  
  const toggle = useCallback(() => setState(s => !s), []);
  const setTrue = useCallback(() => setState(true), []);
  const setFalse = useCallback(() => setState(false), []);
  
  return [state, toggle, setTrue, setFalse];
}

export default function Example() {
  const [on, toggle, turnOn, turnOff] = useToggle();

  return (
    <div>
      <p>{on ? 'ON' : 'OFF'}</p>
      <button onClick={toggle}>Toggle</button>
      <button onClick={turnOn}>ON</button>
      <button onClick={turnOff}>OFF</button>
    </div>
  );
}

컴포넌트가 리렌더링 될때마다 함수를 새로 생성하는 것을 방지하고자 useCallback을 사용했습니다.

 

이렇게 타입스크립트를 적용한 예시 코드까지 확인해 봤습니다! 커스텀 훅을 구현할 때 도움이 되면 좋겠네요 :)

다음 포스팅부터 React 19에 새로 생긴 훅이나 기능들을 본격적으로 다루지 않을까 싶습니다. 읽어주셔서 감사합니다!

+ Recent posts