React Hooks란?

Hooks는 함수형 컴포넌트에서 상태 관리, 사이드 이펙트 처리, 성능 최적화 등을 가능하게 하는 함수입니다. React 16.8에서 도입되어 현재 React 개발의 핵심이 되었습니다.

Hooks 규칙

  1. 최상위에서만 호출: 조건문, 반복문, 중첩 함수 내에서 호출 금지
  2. React 함수 내에서만 호출: 일반 JavaScript 함수에서 호출 금지
// 잘못된 사용
function BadExample() {
  if (someCondition) {
    const [value, setValue] = useState(""); // 조건문 안에서 호출 금지
  }
}

// 올바른 사용
function GoodExample() {
  const [value, setValue] = useState("");

  if (someCondition) {
    // Hook이 아닌 로직은 조건문 안에서 사용 가능
  }
}

useEffect - 사이드 이펙트 처리

기본 사용법

import { useState, useEffect } from "react";

function UserProfile({ userId }: { userId: number }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // 사이드 이펙트 실행
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]); // userId가 변경될 때만 실행

  if (loading) return <p>로딩 ...</p>;
  if (!user) return <p>사용자를 찾을  없습니다.</p>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

의존성 배열 패턴

function EffectPatterns() {
  const [count, setCount] = useState(0);

  // 1. 마운트 시 1회만 실행
  useEffect(() => {
    console.log("컴포넌트 마운트");
  }, []);

  // 2. 특정 값 변경 시 실행
  useEffect(() => {
    console.log("count 변경:", count);
  }, [count]);

  // 3. 매 렌더링마다 실행 (의존성 배열 생략)
  useEffect(() => {
    console.log("렌더링됨");
  });

  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

클린업 함수

컴포넌트 언마운트 시 또는 이펙트 재실행 전에 정리 작업을 수행합니다.

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    // 클린업: 컴포넌트 언마운트 시 인터벌 정리
    return () => clearInterval(interval);
  }, []);

  return <p>경과 시간: {seconds}</p>;
}

function EventListener() {
  const [windowWidth, setWindowWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWindowWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);

    // 클린업: 이벤트 리스너 제거
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return <p> 너비: {windowWidth}px</p>;
}

useRef - DOM 접근과 값 유지

DOM 요소 접근

import { useRef, useEffect } from "react";

function AutoFocusInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // 마운트 시 자동 포커스
    inputRef.current?.focus();
  }, []);

  return <input ref={inputRef} placeholder="자동 포커스" />;
}

렌더링 없이 값 유지

useRef는 값이 변경되어도 리렌더링을 발생시키지 않습니다.

function StopWatch() {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const intervalRef = useRef<number | null>(null);

  const start = () => {
    if (isRunning) return;
    setIsRunning(true);
    intervalRef.current = setInterval(() => {
      setTime((prev) => prev + 10);
    }, 10);
  };

  const stop = () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
    }
    setIsRunning(false);
  };

  const reset = () => {
    stop();
    setTime(0);
  };

  return (
    <div>
      <p>{(time / 1000).toFixed(2)}</p>
      <button onClick={start}>시작</button>
      <button onClick={stop}>정지</button>
      <button onClick={reset}>초기화</button>
    </div>
  );
}

이전 값 기억하기

function PreviousValue({ value }: { value: number }) {
  const prevRef = useRef<number>(value);

  useEffect(() => {
    prevRef.current = value;
  }, [value]);

  return (
    <p>
      현재: {value}, 이전: {prevRef.current}
    </p>
  );
}

useMemo - 계산 결과 캐싱

비용이 큰 계산의 결과를 메모이제이션합니다.

import { useState, useMemo } from "react";

function ExpensiveList({ items, filter }: { items: Item[]; filter: string }) {
  // filter가 변경될 때만 재계산
  const filteredItems = useMemo(() => {
    console.log("필터링 실행");
    return items.filter((item) =>
      item.name.toLowerCase().includes(filter.toLowerCase())
    );
  }, [items, filter]);

  return (
    <ul>
      {filteredItems.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

정렬과 통계 계산

function Dashboard({ data }: { data: number[] }) {
  const stats = useMemo(() => {
    const sorted = [...data].sort((a, b) => a - b);
    const sum = data.reduce((acc, val) => acc + val, 0);
    const avg = sum / data.length;
    const min = sorted[0];
    const max = sorted[sorted.length - 1];
    return { sum, avg, min, max };
  }, [data]);

  return (
    <div>
      <p>합계: {stats.sum}</p>
      <p>평균: {stats.avg.toFixed(2)}</p>
      <p>최소: {stats.min} / 최대: {stats.max}</p>
    </div>
  );
}

useCallback - 함수 메모이제이션

함수의 참조를 유지하여 불필요한 자식 컴포넌트 리렌더링을 방지합니다.

import { useState, useCallback, memo } from "react";

// memo로 감싼 자식 컴포넌트
const TodoItem = memo(function TodoItem({
  todo,
  onToggle,
  onDelete,
}: {
  todo: Todo;
  onToggle: (id: number) => void;
  onDelete: (id: number) => void;
}) {
  console.log(`TodoItem 렌더링: ${todo.text}`);
  return (
    <li>
      <span
        style={{ textDecoration: todo.completed ? "line-through" : "none" }}
        onClick={() => onToggle(todo.id)}
      >
        {todo.text}
      </span>
      <button onClick={() => onDelete(todo.id)}>삭제</button>
    </li>
  );
});

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [input, setInput] = useState("");

  // useCallback으로 함수 참조 안정화
  const handleToggle = useCallback((id: number) => {
    setTodos((prev) =>
      prev.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  }, []);

  const handleDelete = useCallback((id: number) => {
    setTodos((prev) => prev.filter((todo) => todo.id !== id));
  }, []);

  const handleAdd = () => {
    if (!input.trim()) return;
    setTodos((prev) => [
      ...prev,
      { id: Date.now(), text: input, completed: false },
    ]);
    setInput("");
  };

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={(e) => e.key === "Enter" && handleAdd()}
      />
      <button onClick={handleAdd}>추가</button>
      <ul>
        {todos.map((todo) => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={handleToggle}
            onDelete={handleDelete}
          />
        ))}
      </ul>
    </div>
  );
}

useReducer - 복잡한 상태 관리

useState보다 복잡한 상태 로직을 체계적으로 관리합니다.

import { useReducer } from "react";

// 상태 타입
interface CartState {
  items: CartItem[];
  total: number;
}

// 액션 타입
type CartAction =
  | { type: "ADD_ITEM"; payload: CartItem }
  | { type: "REMOVE_ITEM"; payload: number }
  | { type: "UPDATE_QUANTITY"; payload: { id: number; quantity: number } }
  | { type: "CLEAR_CART" };

// 리듀서 함수
function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "ADD_ITEM": {
      const existing = state.items.find((i) => i.id === action.payload.id);
      if (existing) {
        const items = state.items.map((i) =>
          i.id === action.payload.id
            ? { ...i, quantity: i.quantity + 1 }
            : i
        );
        return { items, total: calcTotal(items) };
      }
      const items = [...state.items, { ...action.payload, quantity: 1 }];
      return { items, total: calcTotal(items) };
    }
    case "REMOVE_ITEM": {
      const items = state.items.filter((i) => i.id !== action.payload);
      return { items, total: calcTotal(items) };
    }
    case "UPDATE_QUANTITY": {
      const items = state.items.map((i) =>
        i.id === action.payload.id
          ? { ...i, quantity: action.payload.quantity }
          : i
      );
      return { items, total: calcTotal(items) };
    }
    case "CLEAR_CART":
      return { items: [], total: 0 };
    default:
      return state;
  }
}

function ShoppingCart() {
  const [cart, dispatch] = useReducer(cartReducer, { items: [], total: 0 });

  return (
    <div>
      <h2>장바구니 ({cart.items.length})</h2>
      {cart.items.map((item) => (
        <div key={item.id}>
          <span>{item.name} x {item.quantity}</span>
          <button onClick={() => dispatch({ type: "REMOVE_ITEM", payload: item.id })}>
            삭제
          </button>
        </div>
      ))}
      <p>총액: {cart.total.toLocaleString()}</p>
      <button onClick={() => dispatch({ type: "CLEAR_CART" })}>
        장바구니 비우기
      </button>
    </div>
  );
}

커스텀 Hook

반복되는 로직을 재사용 가능한 Hook으로 추출합니다.

useLocalStorage

function useLocalStorage<T>(key: string, initialValue: T) {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value: T | ((val: T) => T)) => {
    const valueToStore = value instanceof Function ? value(storedValue) : value;
    setStoredValue(valueToStore);
    window.localStorage.setItem(key, JSON.stringify(valueToStore));
  };

  return [storedValue, setValue] as const;
}

// 사용
function Settings() {
  const [theme, setTheme] = useLocalStorage("theme", "light");
  const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);

  return (
    <div>
      <select value={theme} onChange={(e) => setTheme(e.target.value)}>
        <option value="light">라이트</option>
        <option value="dark">다크</option>
      </select>
      <input
        type="range"
        min={12}
        max={24}
        value={fontSize}
        onChange={(e) => setFontSize(Number(e.target.value))}
      />
    </div>
  );
}

useFetch

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

function useFetch<T>(url: string): FetchState<T> {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    loading: true,
    error: null,
  });

  useEffect(() => {
    const controller = new AbortController();

    setState({ data: null, loading: true, error: null });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, loading: false, error: null }))
      .catch((err) => {
        if (err.name !== "AbortError") {
          setState({ data: null, loading: false, error: err.message });
        }
      });

    return () => controller.abort();
  }, [url]);

  return state;
}

// 사용
function UserList() {
  const { data: users, loading, error } = useFetch<User[]>("/api/users");

  if (loading) return <p>로딩 ...</p>;
  if (error) return <p>에러: {error}</p>;

  return (
    <ul>
      {users?.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

useDebounce

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

  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// 사용 - 검색 입력 디바운싱
function SearchInput() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    if (debouncedQuery) {
      // 300ms 후에 API 호출
      console.log("검색:", debouncedQuery);
    }
  }, [debouncedQuery]);

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

Context API - 전역 상태 관리

Props drilling 없이 컴포넌트 트리 전체에서 데이터를 공유합니다.

import { createContext, useContext, useState } from "react";

// Context 생성
interface AuthContext {
  user: User | null;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

const AuthContext = createContext<AuthContext | null>(null);

// Provider 컴포넌트
function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const login = async (email: string, password: string) => {
    const res = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });
    const data = await res.json();
    setUser(data.user);
  };

  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// 커스텀 Hook으로 사용 편의성 향상
function useAuth() {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth는 AuthProvider 내에서 사용해야 합니다");
  }
  return context;
}

// 사용
function Navbar() {
  const { user, logout } = useAuth();

  return (
    <nav>
      {user ? (
        <>
          <span>{user.name} 환영합니다</span>
          <button onClick={logout}>로그아웃</button>
        </>
      ) : (
        <a href="/login">로그인</a>
      )}
    </nav>
  );
}

// App에서 Provider로 감싸기
function App() {
  return (
    <AuthProvider>
      <Navbar />
      <main>{/* ... */}</main>
    </AuthProvider>
  );
}

마무리

React Hooks를 정리하면:

Hook용도
useState컴포넌트 상태 관리
useEffect사이드 이펙트 (API 호출, 이벤트 리스너)
useRefDOM 접근, 렌더링 없이 값 유지
useMemo비용 큰 계산 결과 캐싱
useCallback함수 참조 안정화
useReducer복잡한 상태 로직 관리
useContext전역 상태 공유
커스텀 Hook재사용 가능한 로직 추출

Hooks를 활용하면 클래스 컴포넌트 없이도 React의 모든 기능을 효과적으로 사용할 수 있으며, 로직의 재사용성과 코드의 가독성을 크게 향상시킬 수 있습니다.