C.W.K.
Stream
Lesson 04 of 07 · published

커스텀 hook — stateful 로직 공유

~15 min · custom-hooks, abstraction, reuse

Level 0React 입문자
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
커스텀 hook = 이름이 use 로 시작하고 다른 hook 호출하는 함수. 규칙 전부. 이름 관례가 linter 의 Rules of Hooks 강제 가능하게.

커스텀 hook 자격 생긴 시점

같은 hook 기반 작업 하는 컴포넌트 둘 이상. 중복이 JSX 가 아니라 useState + useEffect + cleanup 춤. 그 춤을 함수로 추출. 두 컴포넌트가 이제 함수 호출하고 렌더링에 집중.

이름

useChat, useMousePosition, useDebouncedValue, useLocalStorage. use prefix 필수 — React linter 한테 이 함수가 Rules of Hooks 따른다고 알림 (상단에서 호출, 조건 안에서 절대 안 됨). Prefix 없으면 linter 가 강제 못 함.

커스텀 hook 이 반환하는 것

원하는 거 무엇이든 — 보통 object 또는 tuple. 모양이 hook 의 API 계약:

  • 단일 값: const width = useWindowWidth().
  • Tuple: const [value, setValue] = useLocalStorage(key, initial) — useState 모양 미러.
  • Object: const { messages, send, isStreaming } = useChat(conversationId) — 이름 붙은 출력 여러 개일 때.

반환 값 둘 넘으면 object 써. 이름 붙은 필드가 가독성에서 positional ordering 이김.

cwkPippa 예시

cwkPippa 의 useChat 은 SQLite 백킹 conversation 을 타입 잡힌 계약으로 감싸는 진짜 커스텀 hook: messages, send, regenerate, isStreaming, error. 컴포넌트는 fetch URL, message parent ID, SSE parsing 안 알아도 — 그냥 useChat(conversationId) 호출하고 반환된 object 읽어. Track 5 lesson 7 이 구현 walk; 이 레슨은 왜 + 모양.

호출자 둘, hook 하나. '둘의 규칙' 적용. 한 컴포넌트만 이 로직 필요하면 추출이 과설계일 수 있어. 두 번째 호출자 올 때까지 기다리고 그 후 리팩토링. 조기 hook 추출은 OOP 의 조기 추상화의 React 사촌.

Code

useLocalStorage — state 를 localStorage 와 동기화·tsx
import { useEffect, useState } from "react";

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (v: T) => void] {
  // Lazy initializer 가 localStorage 한 번 읽음.
  const [value, setValue] = useState<T>(() => {
    try {
      const raw = localStorage.getItem(key);
      return raw ? (JSON.parse(raw) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

  // value 바뀔 때마다 localStorage 에 write back.
  useEffect(() => {
    try {
      localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // 쿼터 초과 또는 storage 비활성 — 조용히 무시.
    }
  }, [key, value]);

  return [value, setValue];
}

// 사용 — useState 와 정확히 같지만 reload 간에 persist.
function ThemePicker() {
  const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "dark");
  return (
    <button onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
      Switch to {theme === "dark" ? "light" : "dark"}
    </button>
  );
}
useDebouncedValue — 작고 자주 필요한 hook·tsx
import { useEffect, useState } from "react";

export function useDebouncedValue<T>(value: T, delayMs: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(id);
  }, [value, delayMs]);
  return debounced;
}

// 사용 — 사용자가 타이핑 멈춘 후에만 발화하는 검색 input.
function Search({ query }: { query: string }) {
  const debouncedQuery = useDebouncedValue(query, 300);
  useEffect(() => {
    if (debouncedQuery) fetch(`/api/search?q=${debouncedQuery}`);
  }, [debouncedQuery]);
  return null;
}

External links

Exercise

CSS 미디어 쿼리 매치 여부 반환 + viewport 바뀔 때 업데이트하는 useMediaQuery(query: string): boolean 빌드. window.matchMedia + 이벤트 리스너 사용. 두 컴포넌트에서 사용 — 하나는 mobile vs desktop 레이아웃 렌더, 다른 하나는 'small screen' 경고 표시. 중복 사라지고 둘 다 깨끗하게 읽히는지 확인.
Hint
const mql = window.matchMedia(query); setMatches(mql.matches); 초기, 그 후 mql.addEventListener('change', handler) 로 라이브 업데이트. Cleanup 이 리스너 제거.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고
💛 by 똘이warm

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.