junyeokk
Blog
React Ecosystem·2025. 11. 17

usehooks-ts

React에서 localStorage, 미디어쿼리, 디바운스 같은 흔한 기능을 구현하려면 매번 비슷한 커스텀 훅을 작성하게 된다. useEffect로 이벤트 리스너 등록하고, cleanup 처리하고, SSR 환경에서 window 객체 분기 처리하고... 프로젝트마다 반복되는 보일러플레이트다.

usehooks-ts는 이런 반복 패턴을 타입 안전한 커스텀 훅으로 모아둔 라이브러리다. 외부 의존성 없이 React와 TypeScript만으로 구현되어 있고, 각 훅이 독립적이라 트리 셰이킹도 잘 된다.


직접 구현 vs 라이브러리

"이 정도는 직접 만들지"라는 생각이 들 수 있다. 실제로 간단한 훅이 많다. 예를 들어 useDebounce를 직접 구현하면 이렇게 된다.

typescript
function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

20줄도 안 되는 코드다. 그런데 useLocalStorage를 직접 구현하면 생각보다 고려할 게 많아진다.

typescript
// 단순 버전 - 문제가 많다
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.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue] as const;
}

이 코드의 문제점:

  1. SSR에서 터진다. localStorage는 브라우저 전용 API인데, Next.js 같은 SSR 환경에서는 서버에서 이 코드가 실행되면서 ReferenceError: localStorage is not defined 에러가 발생한다.
  2. 탭 간 동기화가 안 된다. 같은 사이트를 여러 탭에서 열었을 때 한 탭에서 값을 바꿔도 다른 탭에는 반영되지 않는다.
  3. JSON 파싱 에러 처리가 없다. 사용자가 DevTools에서 직접 localStorage를 수정하거나 손상된 값이 들어있으면 JSON.parse()가 터진다.
  4. 키 변경 시 동작이 이상하다. key가 바뀌면 이전 키의 값이 남아있게 된다.

usehooks-ts의 useLocalStorage는 이 모든 경우를 처리한다. storage 이벤트를 리스닝해서 탭 간 동기화를 지원하고, SSR 환경에서는 initialValue를 반환하며, try-catch로 파싱 실패에 대비한다. 단순해 보이는 훅도 프로덕션 레벨로 올리면 엣지 케이스가 많다는 뜻이다.


주요 훅 정리

usehooks-ts는 40개 이상의 훅을 제공한다. 전부 알 필요는 없고, 실제로 자주 쓰이는 것들 위주로 살펴본다.

useLocalStorage / useSessionStorage

브라우저 스토리지를 React 상태처럼 사용할 수 있게 해준다.

typescript
import { useLocalStorage } from 'usehooks-ts';

function Sidebar() {
  const [isOpen, setIsOpen] = useLocalStorage('sidebar-open', true);

  return (
    <aside className={isOpen ? 'expanded' : 'collapsed'}>
      <button onClick={() => setIsOpen(prev => !prev)}>
        토글
      </button>
    </aside>
  );
}

useState와 동일한 인터페이스를 사용하기 때문에 기존 상태를 localStorage 기반으로 바꿀 때 변경이 최소화된다. useState('sidebar-open', true)useLocalStorage('sidebar-open', true)로 바꾸기만 하면 된다.

내부적으로 다음과 같이 동작한다:

  1. 초기 렌더링 시 localStorage에서 해당 키의 값을 읽어온다. 없으면 initialValue 사용.
  2. setValue 호출 시 React 상태와 localStorage를 동시에 업데이트한다.
  3. 다른 탭에서 같은 키를 변경하면 storage 이벤트를 감지해서 현재 탭의 상태도 동기화한다.
  4. serializer/deserializer 옵션으로 JSON 외의 직렬화 방식도 사용할 수 있다.
typescript
// v3에서는 options 객체를 세 번째 인자로 전달
const [theme, setTheme] = useLocalStorage('theme', 'light', {
  serializer: (value) => value,          // JSON.stringify 대신 그대로 저장
  deserializer: (value) => value,        // JSON.parse 대신 그대로 읽기
  initializeWithValue: true,             // SSR에서 false로 하면 hydration 이후에 값 로드
});

initializeWithValue 옵션은 SSR 환경에서 중요하다. true(기본값)면 초기 렌더링 시 바로 localStorage를 읽지만, 서버에서는 localStorage가 없으므로 hydration mismatch가 발생할 수 있다. false로 설정하면 초기에는 initialValue를 사용하고, 클라이언트에서 마운트된 후에 localStorage 값을 읽어온다.

useMediaQuery

CSS 미디어 쿼리의 매칭 여부를 React 상태로 가져온다.

typescript
import { useMediaQuery } from 'usehooks-ts';

function ResponsiveLayout() {
  const isMobile = useMediaQuery('(max-width: 767px)');
  const isDesktop = useMediaQuery('(min-width: 1025px)');
  const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');

  if (isMobile) return <MobileView />;
  if (isDesktop) return <DesktopView />;
  return <TabletView />;
}

CSS의 @media 쿼리와 동일한 문자열을 인자로 받는다. 내부적으로 window.matchMedia()를 사용하고, change 이벤트를 리스닝해서 뷰포트가 변할 때마다 상태를 업데이트한다.

직접 구현할 때 놓치기 쉬운 부분이 이벤트 리스너 등록 방식이다. 최신 브라우저는 addEventListener('change', ...)를 지원하지만, Safari 13 이하 같은 구형 브라우저는 addListener()를 사용해야 한다. usehooks-ts는 이런 호환성 처리까지 포함되어 있다.

CSS로 반응형을 처리하면 되는데 왜 JS에서 미디어쿼리가 필요할까? 레이아웃 자체가 달라지는 경우다. 모바일에서는 바텀 시트를, 데스크톱에서는 사이드 패널을 보여줘야 한다면 CSS만으로는 안 되고, 컴포넌트 자체를 조건부로 렌더링해야 한다.

useDebounce

입력값이 변할 때마다 즉시 반응하지 않고, 일정 시간 동안 변화가 없을 때만 반영한다.

typescript
import { useDebounce } from 'usehooks-ts';

function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      searchAPI(debouncedQuery);
    }
  }, [debouncedQuery]);

  return (
    <input
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="검색..."
    />
  );
}

사용자가 타이핑을 멈추고 300ms가 지나야 API 호출이 발생한다. 글자를 입력할 때마다 API를 호출하면 서버 부하도 커지고 네트워크 요청이 쌓이면서 이전 결과가 최신 결과를 덮어쓰는 race condition도 생길 수 있다. 디바운스는 이를 방지하는 가장 기본적인 방법이다.

useClickOutside (useOnClickOutside)

특정 요소 바깥을 클릭하면 콜백이 실행된다. 드롭다운, 모달, 팝오버 닫기에 필수적인 패턴이다.

typescript
import { useOnClickOutside } from 'usehooks-ts';

function Dropdown() {
  const ref = useRef<HTMLDivElement>(null);
  const [isOpen, setIsOpen] = useState(false);

  useOnClickOutside(ref, () => setIsOpen(false));

  return (
    <div ref={ref}>
      <button onClick={() => setIsOpen(true)}>메뉴</button>
      {isOpen && (
        <ul className="dropdown-menu">
          <li>항목 1</li>
          <li>항목 2</li>
        </ul>
      )}
    </div>
  );
}

내부적으로 mousedowntouchstart 이벤트를 document에 등록하고, 클릭된 타겟이 ref 요소의 자식인지 contains()로 확인한다. mousedown을 사용하는 이유는 click보다 먼저 발생하기 때문에 드롭다운 내부의 버튼 클릭과 외부 클릭을 정확하게 구분할 수 있기 때문이다.

useIntersectionObserver

요소가 뷰포트에 보이는지 감지한다. 무한 스크롤, lazy loading, 스크롤 애니메이션 등에 사용된다.

typescript
import { useIntersectionObserver } from 'usehooks-ts';

function LazyImage({ src }: { src: string }) {
  const { ref, isIntersecting } = useIntersectionObserver({
    threshold: 0.1,
    freezeOnceVisible: true,  // 한 번 보이면 더 이상 감지하지 않음
  });

  return (
    <div ref={ref}>
      {isIntersecting ? <img src={src} /> : <Placeholder />}
    </div>
  );
}

freezeOnceVisible 옵션이 편리하다. 이미지 lazy loading처럼 한 번 보이면 이후에는 감지할 필요가 없는 경우, 이 옵션을 켜면 observer를 자동으로 해제한다.

useCopyToClipboard

클립보드에 텍스트를 복사하고 성공 여부를 확인할 수 있다.

typescript
import { useCopyToClipboard } from 'usehooks-ts';

function ShareButton({ url }: { url: string }) {
  const [copiedText, copy] = useCopyToClipboard();

  return (
    <button onClick={() => copy(url)}>
      {copiedText ? '복사됨!' : '링크 복사'}
    </button>
  );
}

내부적으로 navigator.clipboard.writeText()를 사용한다. 이 API는 HTTPS 환경에서만 동작하고, 사용자 인터랙션(클릭 등) 컨텍스트 안에서만 호출할 수 있다는 제약이 있다.


설치와 사용

bash
npm install usehooks-ts

각 훅을 개별 import할 수 있다.

typescript
import { useLocalStorage } from 'usehooks-ts';
import { useMediaQuery } from 'usehooks-ts';
import { useDebounce } from 'usehooks-ts';

번들 사이즈 측면에서, 각 훅이 독립적인 모듈로 분리되어 있기 때문에 사용하지 않는 훅은 번들에 포함되지 않는다. 모던 번들러(webpack 5, Vite, turbopack 등)의 트리 셰이킹이 정상적으로 동작한다.


직접 구현이 나은 경우

usehooks-ts의 모든 훅을 무조건 쓸 필요는 없다. 상황에 따라 직접 구현이 더 나은 경우가 있다.

프로젝트 특화 로직이 섞이는 경우. 예를 들어 useDebounce에 취소 기능이나 leading edge 호출이 필요하다면 라이브러리 훅을 래핑하는 것보다 직접 만드는 게 깔끔하다.

훅 하나만 필요한 경우. useDebounce 하나 때문에 라이브러리 전체를 설치하는 건 과하다. 트리 셰이킹이 되긴 하지만 의존성 하나를 추가하는 것 자체가 관리 포인트다.

동작을 정확히 제어해야 하는 경우. 라이브러리 훅의 내부 동작이 기대와 다를 때가 있다. useLocalStorage의 동기화 타이밍이나 useDebounce의 cleanup 시점 등, 미세한 동작 차이가 중요한 상황에서는 직접 구현이 안전하다.

반대로, 여러 프로젝트에서 반복 사용되는 범용 훅(localStorage, 미디어쿼리, 클립보드 등)은 검증된 라이브러리를 쓰는 게 맞다. 엣지 케이스를 직접 찾아가며 구현하는 시간을 아낄 수 있다.


관련 라이브러리

usehooks-ts 외에도 비슷한 역할을 하는 라이브러리들이 있다.

  • ahooks — Alibaba에서 만든 React 훅 라이브러리. usehooks-ts보다 훨씬 많은 훅(60개+)을 제공하지만 번들 사이즈가 크고 중국어 문서 비중이 높다.
  • react-use — 가장 오래된 React 훅 라이브러리 중 하나. 훅 수가 100개가 넘지만 일부 훅은 관리가 안 되고 있다. TypeScript 지원이 부분적이다.
  • @mantine/hooks — Mantine UI 라이브러리의 훅 패키지. UI 라이브러리와 독립적으로도 사용 가능하다. useDisclosure, useForm 같은 UI 관련 훅이 특히 잘 되어있다.

usehooks-ts의 장점은 가볍고, TypeScript 퍼스트이며, 문서가 명확하다는 점이다. 각 훅의 소스 코드가 짧고 읽기 쉬워서 동작 원리를 이해하기도 좋다.

정리

  • 반복되는 브라우저 API 래핑(localStorage, matchMedia, IntersectionObserver 등)을 검증된 훅으로 대체해서 엣지 케이스 처리 시간을 아낄 수 있다
  • 트리 셰이킹이 잘 되는 구조라 필요한 훅만 번들에 포함되고, TypeScript 타입이 완벽하게 지원된다
  • 직접 구현이 나은 경우도 있다 — 프로젝트 특화 로직이 섞이거나 훅 하나만 필요한 경우에는 라이브러리 의존성 추가보다 직접 작성이 낫다

관련 문서