React + TypeScript 실전 패턴 — Props, Hooks, Context

React에서 TypeScript가 필요한 이유

React 컴포넌트는 props, state, 이벤트, context 등 다양한 데이터가 흐릅니다. JavaScript만으로는 props의 구조가 명확하지 않아 런타임 에러나 잘못된 사용이 발생하기 쉽습니다. TypeScript를 적용하면 컴포넌트 인터페이스가 명시되어 IDE 자동완성, 타입 검사, 리팩토링 안전성을 동시에 확보할 수 있습니다.

이 글에서는 Props 타입 정의, 이벤트 처리, 커스텀 Hooks, Context, 그리고 자주 쓰이는 고급 패턴을 다룹니다.

Props 타입 정의

컴포넌트의 props는 interface 또는 type으로 정의합니다. 선택적 속성, 기본값, children 타입을 명확히 지정합니다.

import { type ReactNode } from "react";

// Props 인터페이스 정의
interface ButtonProps {
  label: string;
  variant?: "primary" | "secondary" | "danger"; // 선택적 + 유니온
  size?: "sm" | "md" | "lg";
  disabled?: boolean;
  onClick: () => void;                          // 이벤트 핸들러
  children?: ReactNode;                          // 자식 요소
}

// 기본값은 구조 분해 할당에서 지정
function Button({
  label,
  variant = "primary",
  size = "md",
  disabled = false,
  onClick,
  children,
}: ButtonProps) {
  // 사이즈별 클래스 매핑
  const sizeClass: Record<string, string> = {
    sm: "px-2 py-1 text-sm",
    md: "px-4 py-2 text-base",
    lg: "px-6 py-3 text-lg",
  };

  return (
    <button
      className={`btn btn-${variant} ${sizeClass[size]}`}
      disabled={disabled}
      onClick={onClick}
    >
      {children ?? label}
    </button>
  );
}

// 사용 시 타입 검사 적용
// <Button label="저장" onClick={() => console.log("저장")} />
// <Button label="삭제" variant="danger" size="lg" onClick={handleDelete} />
// <Button label="" variant="invalid" /> // 에러! "invalid"는 variant에 없음

// 제네릭 Props: 리스트 컴포넌트
interface ListProps<T> {
  items: T[];
  renderItem: (item: T, index: number) => ReactNode;
  keyExtractor: (item: T) => string | number;
  emptyMessage?: string;
}

function List<T>({
  items,
  renderItem,
  keyExtractor,
  emptyMessage = "항목이 없습니다",
}: ListProps<T>) {
  if (items.length === 0) {
    return <p className="text-gray-500">{emptyMessage}</p>;
  }
  return (
    <ul>
      {items.map((item, index) => (
        <li key={keyExtractor(item)}>{renderItem(item, index)}</li>
      ))}
    </ul>
  );
}

// 제네릭 컴포넌트 사용
interface User {
  id: number;
  name: string;
}

const users: User[] = [
  { id: 1, name: "홍길동" },
  { id: 2, name: "김철수" },
];

// <List
//   items={users}
//   renderItem={(user) => <span>{user.name}</span>}
//   keyExtractor={(user) => user.id}
// />

이벤트 처리 타입

React의 이벤트 객체는 React.ChangeEvent, React.MouseEvent 등 전용 타입을 사용합니다.

import { type ChangeEvent, type FormEvent, useState } from "react";

interface LoginForm {
  email: string;
  password: string;
}

function LoginPage() {
  const [form, setForm] = useState<LoginForm>({
    email: "",
    password: "",
  });
  const [errors, setErrors] = useState<Partial<LoginForm>>({});

  // input 이벤트: ChangeEvent에 HTML 요소 타입 지정
  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setForm((prev) => ({ ...prev, [name]: value }));
  };

  // select 이벤트
  const handleSelect = (e: ChangeEvent<HTMLSelectElement>) => {
    console.log("선택된 값:", e.target.value);
  };

  // form submit 이벤트
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // 간단한 검증
    const newErrors: Partial<LoginForm> = {};
    if (!form.email.includes("@")) {
      newErrors.email = "올바른 이메일을 입력하세요";
    }
    if (form.password.length < 8) {
      newErrors.password = "비밀번호는 8자 이상이어야 합니다";
    }

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      return;
    }

    console.log("로그인 시도:", form);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="email"
        type="email"
        value={form.email}
        onChange={handleChange}
        placeholder="이메일"
      />
      {errors.email && <span className="error">{errors.email}</span>}

      <input
        name="password"
        type="password"
        value={form.password}
        onChange={handleChange}
        placeholder="비밀번호"
      />
      {errors.password && <span className="error">{errors.password}</span>}

      <button type="submit">로그인</button>
    </form>
  );
}

커스텀 Hooks 타입

커스텀 Hook의 반환 타입을 명시하면 사용처에서 타입이 정확히 추론됩니다.

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

// 제네릭 데이터 페칭 Hook
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchData = useCallback(async () => {
    setLoading(true);
    setError(null);
    try {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }
      const json: T = await response.json();
      setData(json);
    } catch (err) {
      const message = err instanceof Error ? err.message : "알 수 없는 오류";
      setError(message);
    } finally {
      setLoading(false);
    }
  }, [url]);

  useEffect(() => {
    fetchData();
  }, [fetchData]);

  return { data, loading, error, refetch: fetchData };
}

// 사용 예시
interface Post {
  id: number;
  title: string;
  body: string;
}

function PostList() {
  // T가 Post[]로 추론됨
  const { data: posts, loading, error, refetch } = useFetch<Post[]>(
    "https://jsonplaceholder.typicode.com/posts"
  );

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

  return (
    <div>
      <button onClick={refetch}>새로고침</button>
      <ul>
        {posts?.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

// 로컬 스토리지 Hook
function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? (JSON.parse(item) as T) : initialValue;
    } catch {
      return initialValue;
    }
  });

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

  return [storedValue, setValue];
}

// 사용: 타입 안전한 로컬 스토리지
// const [theme, setTheme] = useLocalStorage<"light" | "dark">("theme", "light");
// setTheme("dark"); // OK
// setTheme("blue"); // 에러! "blue"는 허용되지 않음

Context 타입 정의

Context는 createContext에 타입을 지정하고, Provider와 커스텀 Hook을 함께 만드는 것이 일반적인 패턴입니다.

import { createContext, useContext, useReducer, type ReactNode } from "react";

// 상태 타입 정의
interface AuthState {
  user: { id: number; name: string; email: string } | null;
  isAuthenticated: boolean;
  loading: boolean;
}

// 액션 타입 정의 (discriminated union)
type AuthAction =
  | { type: "LOGIN_START" }
  | { type: "LOGIN_SUCCESS"; payload: { id: number; name: string; email: string } }
  | { type: "LOGIN_FAILURE"; payload: string }
  | { type: "LOGOUT" };

// Context 타입 정의
interface AuthContextType {
  state: AuthState;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
}

// Context 생성 (null 초기값 + 커스텀 Hook으로 안전하게 접근)
const AuthContext = createContext<AuthContextType | null>(null);

// 커스텀 Hook: null 체크 포함
function useAuth(): AuthContextType {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error("useAuth는 AuthProvider 내부에서만 사용할 수 있습니다");
  }
  return context;
}

// Reducer
function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case "LOGIN_START":
      return { ...state, loading: true };
    case "LOGIN_SUCCESS":
      return { user: action.payload, isAuthenticated: true, loading: false };
    case "LOGIN_FAILURE":
      return { user: null, isAuthenticated: false, loading: false };
    case "LOGOUT":
      return { user: null, isAuthenticated: false, loading: false };
    default:
      return state;
  }
}

// Provider 컴포넌트
function AuthProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(authReducer, {
    user: null,
    isAuthenticated: false,
    loading: false,
  });

  const login = async (email: string, password: string) => {
    dispatch({ type: "LOGIN_START" });
    try {
      // API 호출 (예시)
      const response = await fetch("/api/login", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email, password }),
      });
      const user = await response.json();
      dispatch({ type: "LOGIN_SUCCESS", payload: user });
    } catch {
      dispatch({ type: "LOGIN_FAILURE", payload: "로그인에 실패했습니다" });
    }
  };

  const logout = () => dispatch({ type: "LOGOUT" });

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

// 사용 컴포넌트
function UserProfile() {
  const { state, logout } = useAuth();
  // state.user가 null | User로 타입 좁히기 적용됨

  if (!state.isAuthenticated || !state.user) {
    return <p>로그인이 필요합니다</p>;
  }

  return (
    <div>
      <h2>{state.user.name}</h2>
      <p>{state.user.email}</p>
      <button onClick={logout}>로그아웃</button>
    </div>
  );
}

실전 팁

  • Props에 interface를 사용: 선언 병합과 확장이 가능하여 컴포넌트 props에 적합합니다.
  • 이벤트 타입은 React 전용 타입 사용: ChangeEvent<HTMLInputElement>, MouseEvent<HTMLButtonElement> 등을 사용하면 target 속성의 타입이 정확히 추론됩니다.
  • 커스텀 Hook 반환 타입 명시: 복잡한 반환 구조는 인터페이스로 정의하면 사용처에서 명확합니다.
  • Context는 null 초기값 + 커스텀 Hook: createContext(null)로 생성하고 커스텀 Hook에서 null 체크를 포함하면 Provider 밖에서의 실수를 방지할 수 있습니다.
  • 제네릭 컴포넌트: List<T>, Table<T> 같은 범용 컴포넌트는 제네릭으로 만들면 다양한 데이터 타입에 재사용할 수 있습니다.
  • as const 활용: 리터럴 타입을 유지해야 할 때 as const를 사용하면 타입이 넓어지는 것을 방지합니다.

이 글이 도움이 되었나요?