본문 바로가기

훅과 상태 관리

useState·useEffect·useContext·useRef와 커스텀 훅의 타입을 선언해 상태·부수 효과·DOM 참조의 안전성을 유지합니다.

React Hooks는 React 16.8부터 도입된 기능으로, 함수형 컴포넌트에서 상태(state)와 생명주기(lifecycle) 기능을 사용할 수 있게 해줍니다.

이전에는 클래스형 컴포넌트에서만 가능했던 기능들을 함수형 컴포넌트에서 더 간결하고 재사용 가능한 방식으로 구현할 수 있게 되면서, React 개발의 패러다임을 크게 변화시켰습니다.

타입스크립트는 이러한 React Hooks와 완벽하게 통합되어, 상태 관리 및 부수 효과(side effects) 처리에 강력한 타입 안전성을 제공합니다.

이 절에서는 가장 일반적으로 사용되는 Hooks인 useState, useEffect, useContext, useRef, 그리고 사용자 정의 훅을 타입스크립트와 함께 사용하는 방법을 살펴보겠습니다.


각 예제의 기본 내보내기 컴포넌트를 별도 .tsx 파일로 저장하고 App.tsx에서 하나씩 선택해 실행합니다. useCounter만 JSX가 없는 .ts 파일이고, 사용하는 CustomCounter는 .tsx 파일입니다. 원 코드 블록의 typescript 표기에 jsx가 추가되어 있거나 파일명이 .tsx인 경우에는 실제 파일의 JSX 문법까지 처리하는 설정이 필요합니다. src/hooks와 src/components의 상대 import 경로도 그대로 맞춥니다.

verbatimModuleSyntax를 켠 프로젝트에서는 Context 예제의 ReactNode를 타입 전용으로 가져와야 합니다. 원 코드를 보존한 복사본에서 import의 ReactNode 앞에 type을 붙여 type ReactNode로 바꾸세요. 이는 Provider의 동작을 바꾸는 수정이 아니라 타입 선언을 런타임 값 import와 구분하는 수정입니다.

UserProfile은 세 지역 상태를 별도로 저장합니다. 처음에는 Please fetch a user.가 보이고, Fetch User 1이 완료되면 Alice와 [email protected]을 표시합니다. 그 상태에서 Fetch User 99를 누르면 요청 중에도 이전 user가 남아 있어 Alice를 계속 보여주며 두 버튼에는 Loading...이 표시됩니다. 완료 뒤에는 Error: User not found!가 보이고 user는 null이 됩니다. 여기의 Promise와 타이머는 서버·네트워크 호출이 아닙니다. 로딩 시작과 동시에 이전 사용자를 지우는 정책을 원 구현이 이미 사용한다고 읽지 않습니다.

useState 훅과 타입스크립트

useState는 함수형 컴포넌트에서 상태를 선언하고 관리하는 가장 기본적인 훅입니다.

타입스크립트는 useState의 초기값을 기반으로 상태의 타입을 자동으로 추론하지만, 명시적으로 타입을 지정해주는 것이 더 안전하고 명확할 때가 많습니다.

기본 타입 추론
import React, { useState } from 'react';

const SimpleCounter: React.FC = () => {
  // 초기값 0을 통해 `count`는 `number` 타입으로 추론됩니다.
  const [count, setCount] = useState(0);

  // 초기값 'Hello'를 통해 `message`는 `string` 타입으로 추론됩니다.
  const [message, setMessage] = useState('Hello');

  const increment = () => setCount(count + 1);
  const updateMessage = (newMessage: string) => setMessage(newMessage);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <p>Message: {message}</p>
      <button onClick={() => updateMessage('New Message')}>Update Message</button>
    </div>
  );
};

export default SimpleCounter;
명시적인 타입 지정

초기값이 null일 수 있거나, 나중에 더 복잡한 객체 타입이 될 수 있는 경우, 제네릭을 사용하여 명시적으로 타입을 지정하는 것이 중요합니다.

import React, { useState } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

const UserProfile: React.FC = () => {
  // `user`는 `User` 타입이거나 `null`일 수 있음을 명시합니다.
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);

  const fetchUserProfile = async (userId: number) => {
    setIsLoading(true);
    setError(null); // 이전 에러 초기화
    try {
      // 가상 API 호출
      const response = await new Promise<User | null>((resolve) => {
        setTimeout(() => {
          if (userId === 1) {
            resolve({ id: 1, name: 'Alice', email: '[email protected]' });
          } else {
            // Reject를 통해 에러 시뮬레이션
            // throw new Error('User not found!');
            resolve(null); // 사용자 미존재 케이스
          }
        }, 1000);
      });

      if (response && response.id) { // 유효한 응답인지 확인
        setUser(response);
      } else {
        setError('User not found!');
        setUser(null);
      }
    } catch (err: any) {
      setError(err.message || 'Failed to fetch user.');
      setUser(null);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      <h2>User Profile</h2>
      <button onClick={() => fetchUserProfile(1)} disabled={isLoading}>
        {isLoading ? 'Loading...' : 'Fetch User 1'}
      </button>
      <button onClick={() => fetchUserProfile(99)} disabled={isLoading}>
        {isLoading ? 'Loading...' : 'Fetch User 99'}
      </button>

      {error && <p style={{ color: 'red' }}>Error: {error}</p>}
      {user ? (
        <div>
          <p>ID: {user.id}</p>
          <p>Name: {user.name}</p>
          <p>Email: {user.email}</p>
        </div>
      ) : (
        !isLoading && !error && <p>Please fetch a user.</p>
      )}
    </div>
  );
};

export default UserProfile;

useEffect 훅과 타입스크립트

Effect는 setup·cleanup과 현재 요청 판정으로 외부 시스템을 동기화한다 — 상태 추적

effect setup/cleanup과 비동기 응답의 현재/오래된 소유권이 달라진다. 본문 cleanup은 로그만 있으므로 취소·낡은 결과 차단은 설계 보강임을 표시해야 한다.

커밋된 컴포넌트에서 Effect setup과 비동기 작업을 시작하고, 결과가 현재인지 확인해 반영하거나 무시하는 설계. cleanup의 시점은 요청 완료와 별개이다.

EFFECT / SYNCHRONIZATION / RESULT OWNERSHIP

Effect의 setup·cleanup과 비동기 결과 반영을 구분한다

커밋 뒤 setup이 외부 작업을 시작합니다. 의존성 변경 때 이전 cleanup 뒤 새 setup을 실행하고, unmount 때 cleanup합니다. 아래 그림의 현재 요청 판정은 본문 로그 cleanup에 추가하는 설계입니다.

Effect의 setup·cleanup과 비동기 결과 반영을 구분한다커밋된 컴포넌트에서 Effect setup과 비동기 작업을 시작하고, 결과가 현재인지 확인해 반영하거나 무시하는 설계. cleanup의 시점은 요청 완료와 별개이다.컴포넌트 커밋외부 동기화 필요Effect setup요청·연결 시작비동기 결과 도착성공 또는 실패현재 요청 판정identity / ignore현재 결과 반영data 또는 error이전 결과 무시성공·실패 모두현재이전
결과의 현재성에서 반영과 무시를 나눈다커밋→setup→비동기 결과 도착→현재 요청 판정 뒤 현재 결과 반영 또는 이전 결과 무시의 두 독립 결과로 갈린다. 원 여섯 노드·다섯 간선을 유지하며 두 결과 사이에는 간선이 없다. cleanup의 의존성 변경·unmount 시점은 별도 수명이고 원 DataFetcher에는 현재성 검사가 없음을 지원 설명과 함께 읽는다.컴포넌트 커밋외부 동기화 필요Effect setup요청 · 연결 시작비동기 결과 도착성공 또는 실패현재 요청 판정identity / ignore현재 결과 반영data 또는 error이전 결과 무시성공 · 실패 모두현재이전

SETUP

외부 시스템과 동기화

구독·연결·요청 등 외부 작업이 필요할 때 Effect를 사용합니다. 렌더에서 계산할 수 있는 파생값을 별도 Effect로 동기화할 필요는 없습니다.

CLEANUP TIMING

결과 도착과 별개의 수명

의존성이 바뀌면 이전 값의 cleanup 뒤 새 값의 setup을 실행합니다. unmount 때 마지막 cleanup을 실행하며, 요청 성공·실패 자체가 cleanup을 실행시키는 것은 아닙니다.

ASYNC OWNERSHIP

상태 반영 전에 현재성 검사

ignore flag나 요청 identity로 이전 성공·실패가 최신 상태를 덮어쓰지 않게 합니다. 취소 API를 지원하는 자원은 해제·취소하고, 취소가 모든 이후 처리를 자동 차단한다고 가정하지 않습니다.

LESSON LIMIT

교재 cleanup은 로그만 출력

DataFetcher의 1.5초 timer는 cleanup에서 clearTimeout하지 않으며 오래된 결과도 차단하지 않습니다. 그림의 현재성 검사는 설명 확장이고 구현되어 있다고 주장하지 않습니다.

DEVELOPMENT CHECK

빈 의존성 배열과 Strict Mode

[]도 개발 Strict Mode의 추가 setup → cleanup → setup 검사를 막지 않습니다. cleanup이 setup의 외부 작업을 해제하는지 확인합니다. Effect는 서버 렌더에서 실행되지 않습니다.

외부 작업의 수명 관리와 결과의 현재성은 두 계약입니다. 실제 예제의 cleanup 구현을 확인하고 필요한 자원 해제와 결과 무시를 추가합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
setupA현재 소유자: A / UI: 요청 중현재 입력의 Effect가 요청 A를 시작한다cleanup 로그 예제를 요청 안전장치 구현으로 오인하지 않는다
입력 B commit이전 A: 권한 종료 모델 / 현재: B이전 cleanup 뒤 새 setup B를 시작하는 보강 모델이다cancel/stale gate는 본문 코드에 추가할 설계 가정이다
현재 결과 채택B: 현재 UI 반영 / A: 무시 모델B 결과만 반영하고 늦은 A는 무시하는 확장 계약이다실제 source의 로그만으로 이 안전성이 완성되었다고 하지 않는다

Effect는 setup·cleanup과 현재 요청 판정으로 외부 시스템을 동기화한다 — 상태 추적

effect setup/cleanup과 비동기 응답의 현재/오래된 소유권이 달라진다. 본문 cleanup은 로그만 있으므로 취소·낡은 결과 차단은 설계 보강임을 표시해야 한다.

커밋된 컴포넌트에서 Effect setup과 비동기 작업을 시작하고, 결과가 현재인지 확인해 반영하거나 무시하는 설계. cleanup의 시점은 요청 완료와 별개이다.

EFFECT / SYNCHRONIZATION / RESULT OWNERSHIP

Effect의 setup·cleanup과 비동기 결과 반영을 구분한다

커밋 뒤 setup이 외부 작업을 시작합니다. 의존성 변경 때 이전 cleanup 뒤 새 setup을 실행하고, unmount 때 cleanup합니다. 아래 그림의 현재 요청 판정은 본문 로그 cleanup에 추가하는 설계입니다.

Effect의 setup·cleanup과 비동기 결과 반영을 구분한다커밋된 컴포넌트에서 Effect setup과 비동기 작업을 시작하고, 결과가 현재인지 확인해 반영하거나 무시하는 설계. cleanup의 시점은 요청 완료와 별개이다.컴포넌트 커밋외부 동기화 필요Effect setup요청·연결 시작비동기 결과 도착성공 또는 실패현재 요청 판정identity / ignore현재 결과 반영data 또는 error이전 결과 무시성공·실패 모두현재이전
결과의 현재성에서 반영과 무시를 나눈다커밋→setup→비동기 결과 도착→현재 요청 판정 뒤 현재 결과 반영 또는 이전 결과 무시의 두 독립 결과로 갈린다. 원 여섯 노드·다섯 간선을 유지하며 두 결과 사이에는 간선이 없다. cleanup의 의존성 변경·unmount 시점은 별도 수명이고 원 DataFetcher에는 현재성 검사가 없음을 지원 설명과 함께 읽는다.컴포넌트 커밋외부 동기화 필요Effect setup요청 · 연결 시작비동기 결과 도착성공 또는 실패현재 요청 판정identity / ignore현재 결과 반영data 또는 error이전 결과 무시성공 · 실패 모두현재이전

SETUP

외부 시스템과 동기화

구독·연결·요청 등 외부 작업이 필요할 때 Effect를 사용합니다. 렌더에서 계산할 수 있는 파생값을 별도 Effect로 동기화할 필요는 없습니다.

CLEANUP TIMING

결과 도착과 별개의 수명

의존성이 바뀌면 이전 값의 cleanup 뒤 새 값의 setup을 실행합니다. unmount 때 마지막 cleanup을 실행하며, 요청 성공·실패 자체가 cleanup을 실행시키는 것은 아닙니다.

ASYNC OWNERSHIP

상태 반영 전에 현재성 검사

ignore flag나 요청 identity로 이전 성공·실패가 최신 상태를 덮어쓰지 않게 합니다. 취소 API를 지원하는 자원은 해제·취소하고, 취소가 모든 이후 처리를 자동 차단한다고 가정하지 않습니다.

LESSON LIMIT

교재 cleanup은 로그만 출력

DataFetcher의 1.5초 timer는 cleanup에서 clearTimeout하지 않으며 오래된 결과도 차단하지 않습니다. 그림의 현재성 검사는 설명 확장이고 구현되어 있다고 주장하지 않습니다.

DEVELOPMENT CHECK

빈 의존성 배열과 Strict Mode

[]도 개발 Strict Mode의 추가 setup → cleanup → setup 검사를 막지 않습니다. cleanup이 setup의 외부 작업을 해제하는지 확인합니다. Effect는 서버 렌더에서 실행되지 않습니다.

외부 작업의 수명 관리와 결과의 현재성은 두 계약입니다. 실제 예제의 cleanup 구현을 확인하고 필요한 자원 해제와 결과 무시를 추가합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
setupA현재 소유자: A / UI: 요청 중현재 입력의 Effect가 요청 A를 시작한다cleanup 로그 예제를 요청 안전장치 구현으로 오인하지 않는다
입력 B commit이전 A: 권한 종료 모델 / 현재: B이전 cleanup 뒤 새 setup B를 시작하는 보강 모델이다cancel/stale gate는 본문 코드에 추가할 설계 가정이다
현재 결과 채택B: 현재 UI 반영 / A: 무시 모델B 결과만 반영하고 늦은 A는 무시하는 확장 계약이다실제 source의 로그만으로 이 안전성이 완성되었다고 하지 않는다

useEffect는 커밋된 컴포넌트를 외부 시스템과 동기화할 때 사용됩니다. 그림은 현재 요청 확인과 취소를 추가한 설계이며, 아래 예제의 cleanup은 로그만 출력하므로 타이머를 취소하거나 오래된 응답을 차단하지 않습니다. 빈 의존성 배열도 개발 Strict Mode의 추가 setup→cleanup→setup 검사를 막지 않습니다.

데이터 가져오기, 구독 설정, DOM 직접 조작 등이 여기에 해당합니다.

useEffect 자체는 특정 타입을 요구하지 않지만, 그 안에서 사용되는 함수들이나 데이터의 타입 안전성을 유지하는 것이 중요합니다.

import React, { useState, useEffect } from 'react';

const DataFetcher: React.FC = () => {
  const [data, setData] = useState<string | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<string | null>(null);

  // 빈 의존성: 마운트 시 setup, 개발 Strict Mode에서는 추가 setup/cleanup 점검 가능
  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        // 가상 API 호출 (Promise<string> 반환)
        const response = await new Promise<string>((resolve, reject) => {
          setTimeout(() => {
            const success = Math.random() > 0.3; // 70% 확률로 성공
            if (success) {
              resolve('Fetched data successfully!');
            } else {
              reject(new Error('Failed to fetch data!'));
            }
          }, 1500);
        });
        setData(response);
      } catch (err: any) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // 클린업 함수: 컴포넌트 언마운트 시 또는 의존성 변경 전 호출
    return () => {
      console.log('Cleanup for DataFetcher (e.g., cancel subscriptions)');
      // 구독 해제, 타이머 클리어 등
    };
  }, []); // 빈 배열: 마운트 setup/언마운트 cleanup; 개발 Strict Mode에서는 추가 주기 가능

  if (loading) return <p>Loading data...</p>;
  if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;

  return (
    <div>
      <h2>Fetched Data:</h2>
      <p>{data}</p>
    </div>
  );
};

export default DataFetcher;

예시

원 DataFetcher를 보존하고, 타이머 자원의 cleanup을 확인할 작은 복사 예제를 만듭니다. 원 성공률은 실행할 때마다 달라지므로 이 복사본은 shouldFail로 성공·실패를 고정합니다. 타이머가 끝나기 전에 cleanup하면 clearTimeout으로 예약한 콜백을 취소합니다. 실제 HTTP 요청의 취소나 이미 완료된 Promise의 현재성 검사까지 구현한 코드가 아닙니다.

src/components/DataFetcherSafe.tsx
import { useEffect, useState } from 'react';

export default function DataFetcherSafe({ shouldFail = false }: { shouldFail?: boolean }) {
  const [data, setData] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    setData(null);
    setError(null);
    const timer = setTimeout(() => {
      if (shouldFail) {
        setError('연습 실패');
      } else {
        setData('연습 성공');
      }
      setLoading(false);
    }, 500);
    return () => clearTimeout(timer);
  }, [shouldFail]);

  if (loading) return <p>Loading data...</p>;
  if (error !== null) return <p>Error: {error}</p>;
  return <p>{data}</p>;
}

App에서 shouldFail false로 배치하면 Loading data... 뒤에 연습 성공이 보입니다. true로 새로 마운트하면 Error: 연습 실패가 보입니다. 완료 전에 부모가 shouldFail을 바꾸면 이전 cleanup 뒤 새 setup이 새 타이머를 예약합니다. 개발 Strict Mode의 setup→cleanup→setup에서도 첫 예약이 정리되는지 확인합니다. cleanup은 성공 화면이 나타났다는 이유만으로 실행되는 것이 아닙니다.

그림의 요청 identity·ignore는 실제 네트워크나 Promise 결과를 다룰 때의 설계 확장입니다. 이번 복사본은 아직 실행되지 않은 타이머를 취소하는 범위만 보여줍니다. 외부 작업마다 해제 가능한 자원과 이후 상태 반영을 막는 조건을 따로 확인해야 합니다.

useContext 훅과 타입스크립트

useContext는 React Context API를 사용하여 컴포넌트 트리를 통해 데이터를 전달할 때 사용됩니다.

타입스크립트와 함께 사용할 때는 Context의 초기값과 Provider의 value Prop의 타입이 일치하도록 정의하는 것이 중요합니다.

import React, { createContext, useContext, useState, ReactNode } from 'react';

// 1. Context에 저장될 데이터의 타입 정의
interface ThemeContextType {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

// 2. Context 생성: 초기값에 null을 허용하고 싶다면 제네릭에 null을 포함하고,
//    as 키워드로 타입 단언을 하거나, Provider 사용 시점의 초기값을 잘 설정해야 합니다.
//    가장 좋은 방법은 초기값을 최대한 실제 값과 유사하게 설정하거나,
//    Context를 사용하는 컴포넌트에서 null 체크를 하는 것입니다.
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);

// 3. Context Provider 컴포넌트 생성
interface ThemeProviderProps {
  children: ReactNode;
}

const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  const toggleTheme = () => {
    setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
  };

  // Provider value는 ThemeContextType 인터페이스를 만족해야 합니다.
  const contextValue: ThemeContextType = { theme, toggleTheme };

  return (
    <ThemeContext.Provider value={contextValue}>
      {children}
    </ThemeContext.Provider>
  );
};

// 4. Context를 사용하는 컴포넌트
const ThemeToggleButton: React.FC = () => {
  const themeContext = useContext(ThemeContext);

  if (themeContext === undefined) {
    throw new Error('ThemeToggleButton must be used within a ThemeProvider');
  }

  const { theme, toggleTheme } = themeContext;

  return (
    <button
      onClick={toggleTheme}
      style={{
        background: theme === 'light' ? '#eee' : '#333',
        color: theme === 'light' ? '#333' : '#eee',
        border: '1px solid',
        padding: '10px 20px',
        cursor: 'pointer'
      }}
    >
      Toggle Theme ({theme})
    </button>
  );
};

// 5. App.tsx에서 사용 예시
const ThemeApp: React.FC = () => {
  return (
    <ThemeProvider>
      <div style={{ padding: '20px', border: '1px solid #ccc' }}>
        <h2>Context API Example</h2>
        <ThemeToggleButton />
        <p>This paragraph will dynamically change theme if you apply styles.</p>
      </div>
    </ThemeProvider>
  );
};

export default ThemeApp;

createContext의 초기값을 undefined로 설정하고, useContext를 사용하는 곳에서 undefined 체크를 하는 패턴은 Context가 Provider 없이 사용될 경우의 오류를 방지하는 좋은 방법입니다.


useRef 훅과 타입스크립트

useRef는 주로 DOM 엘리먼트에 직접 접근하거나, 컴포넌트 렌더링 간에 변경되지 않는 가변(Mutable) 값을 저장할 때 사용됩니다.

import React, { useRef, useEffect } from 'react';

const TextInputWithFocusButton: React.FC = () => {
  // input 엘리먼트에 접근하기 위한 ref. HTMLInputElement 또는 null이 될 수 있음을 명시.
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // 컴포넌트 마운트 시 inputRef.current가 존재하면 자동으로 포커스
    if (inputRef.current) {
      inputRef.current.focus();
    }
  }, []); // 마운트 setup; 개발 Strict Mode의 추가 점검 주기 가능

  const handleClick = () => {
    if (inputRef.current) {
      alert(`Current input value: ${inputRef.current.value}`);
      inputRef.current.focus(); // 버튼 클릭 후 다시 포커스
    }
  };

  return (
    <div>
      <h2>useRef Example</h2>
      <input type="text" ref={inputRef} placeholder="Focus me on mount" />
      <button onClick={handleClick}>Show Value & Focus</button>
    </div>
  );
};

export default TextInputWithFocusButton;

useRef의 초기값으로 null을 주면, inputRef.current의 타입은 HTMLInputElement | null이 됩니다.

따라서 inputRef.current를 사용할 때는 항상 null 체크를 해야 합니다.


사용자 정의 훅

여러 컴포넌트에서 동일한 로직(상태 관리, 부수 효과 등)을 공유해야 할 때, 사용자 정의 훅을 생성하여 재사용성을 높일 수 있습니다.

사용자 정의 훅은 use로 시작하는 일반 함수이며, 다른 훅들을 내부적으로 호출할 수 있습니다.

타입스크립트는 사용자 정의 훅의 입력 및 출력에 대한 타입 안정성을 보장합니다.

예시: useCounter 커스텀 훅
src/hooks/useCounter.ts
import { useState, useCallback } from 'react';

interface UseCounterOptions {
  initialValue?: number;
  max?: number;
  min?: number;
}

interface UseCounterReturn {
  count: number;
  increment: (step?: number) => void;
  decrement: (step?: number) => void;
  reset: () => void;
  setCount: React.Dispatch<React.SetStateAction<number>>; // useState의 setCount 함수 타입
}

const useCounter = (options?: UseCounterOptions): UseCounterReturn => {
  const { initialValue = 0, max, min } = options || {};
  const [count, setCount] = useState(initialValue);

  const increment = useCallback((step: number = 1) => {
    setCount(prevCount => {
      const newCount = prevCount + step;
      return max !== undefined ? Math.min(newCount, max) : newCount;
    });
  }, [max]); // max 값이 변경될 때만 increment 함수 재생성

  const decrement = useCallback((step: number = 1) => {
    setCount(prevCount => {
      const newCount = prevCount - step;
      return min !== undefined ? Math.max(newCount, min) : newCount;
    });
  }, [min]); // min 값이 변경될 때만 decrement 함수 재생성

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]); // initialValue가 변경될 때만 reset 함수 재생성

  return { count, increment, decrement, reset, setCount };
};

export default useCounter;
사용자 정의 훅 사용 예시
src/components/CustomCounter.tsx
import React from 'react';
import useCounter from '../hooks/useCounter'; // 사용자 정의 훅 임포트

const CustomCounter: React.FC = () => {
  const { count, increment, decrement, reset } = useCounter({
    initialValue: 5,
    min: 0,
    max: 10
  });

  return (
    <div>
      <h2>Custom Counter ({count})</h2>
      <button onClick={() => increment()}>Increment</button>
      <button onClick={() => increment(2)}>Increment by 2</button>
      <button onClick={() => decrement()}>Decrement</button>
      <button onClick={() => decrement(3)}>Decrement by 3</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
};

export default CustomCounter;

useCounter 훅은 count 상태와 이를 조작하는 함수들을 캡슐화하여, 어떤 컴포넌트에서든 손쉽게 카운터 기능을 추가할 수 있게 합니다.

각 useCounter 호출은 독립된 상태를 만듭니다. 이 예제는 increment에서 상한, decrement에서 하한만 적용하며, 음수 step·외부 setCount·초기값 변경까지 불변식을 강제하는 범용 범위 카운터는 아닙니다.

타입스크립트는 useCounter의 반환값이 UseCounterReturn 인터페이스를 따르는지 검사하여 안정성을 높입니다.


소유한 상태를 독립적으로 추적하기

CustomCounter의 초기값 5에서 Increment by 2를 세 번, Decrement by 3을 세 번 누르고 마지막으로 Reset을 누릅니다. 매 단계 count를 적으세요. 이 버튼들은 양수 step만 사용한다는 조건을 유지합니다. 별도의 독립 가정으로 훅이 노출한 setCount(99)를 직접 호출하면, 원 구현의 max 10이 그 값을 자동으로 막을까요?

App에 CustomCounter를 두 번 배치하고 첫 번째 카운터에서만 Increment by 2를 한 번 누릅니다. 두 count가 어떤 값이 되어야 할까요? 한 Hook 함수를 재사용하는 것과 하나의 상태를 공유하는 것을 구분하세요. 같은 ThemeProvider 아래의 ThemeToggleButton 두 개를 배치해 한 버튼으로 theme를 바꾸는 경우와도 비교합니다.

TextInputWithFocusButton에 hook이라고 입력하고 Show Value & Focus를 누르면 안내와 포커스는 어떻게 되어야 하나요? inputRef.current가 null일 수 있는 초기 시점을 무시하고 .focus()를 바로 호출해도 된다는 결론을 타입 단언으로 만들어도 되는지 설명하세요.

지역 상태, 공유 범위, 자원 수명을 나눈 풀이

카운터는 5→7→9→10→7→4→1→5입니다. increment는 상한을 적용하고 decrement는 하한을 적용하며 reset은 최초 설정값 5를 넣습니다. setCount 자체는 원 useState setter를 노출하므로 99를 넣으면 훅의 increment/decrement clamp를 거치지 않습니다. 원 코드는 모든 입력 경로에서 범위를 강제하는 카운터가 아닙니다. 범위가 업무 규칙이라면 공개 setter와 음수 step·초기값·reset 정책까지 함께 제한해야 합니다.

두 CustomCounter의 상태는 각각의 useCounter 호출이 소유하므로 첫 번째는 7, 두 번째는 5입니다. 반면 같은 Provider 아래 ThemeToggleButton 두 개는 같은 theme 값을 읽습니다. 한 버튼에서 toggleTheme를 호출하면 Provider의 light가 dark로 바뀌어 두 소비자가 함께 새 theme를 봅니다. Provider 밖에서 소비하면 원 guard가 명시적 오류를 발생시키며 기본 light를 자동으로 제공하는 코드가 아닙니다.

안내는 Current input value: hook이고, 그 뒤 입력창에 포커스를 요청합니다. ref는 마운트 전이나 연결 해제 뒤 null일 수 있으므로 원 코드처럼 존재 여부를 확인해야 합니다. 타입 단언은 실제 DOM 요소를 만들지 않습니다. ref의 값 변경 자체가 화면 리렌더링을 요청하는 상태 업데이트인 것도 아닙니다.

Effect의 수명은 useEffect 공식 설명, 커스텀 훅의 상태 소유는 로직 재사용 공식 설명에 연결됩니다.

훅 타입 설계 요약

React Hooks는 함수형 컴포넌트에서 상태 관리 및 부수 효과를 처리하는 현대적이고 효율적인 방법을 제공합니다.

타입스크립트는 이러한 Hooks의 강력한 기능에 정적 타입 검사를 추가하여 다음과 같은 이점을 제공합니다.

  • 오류 방지: 잘못된 타입의 데이터를 상태에 저장하거나, 훅의 인자로 전달하는 실수를 컴파일 시점에 감지합니다.
  • 코드 명확성: 상태와 Props의 타입을 명확하게 선언함으로써 코드의 의도를 쉽게 이해할 수 있습니다.
  • IDE 지원: 타입 정보를 바탕으로 자동 완성, 리팩터링, 코드 탐색 기능이 향상됩니다.
  • 유지보수성: 코드 변경 시 타입 정의를 통해 영향을 받는 부분을 쉽게 파악하고 수정할 수 있습니다.

React와 타입스크립트의 조합은 견고하고 확장 가능한 React 애플리케이션을 구축하는 데 필수적인 요소가 되었습니다.

파생값, 지역 상태, Context 공유, ref, Effect, 커스텀 Hook의 독립적인 역할과 본문 예제의 한계.

HOOK CHOICE / OWNERSHIP AND LIFETIME

저장·공유·외부 동기화의 필요에 맞게 Hook을 조합한다

파생 계산, 지역 상태, 공유 범위, 렌더와 무관한 가변값, 외부 작업은 서로 다른 판단입니다. 한 컴포넌트가 여러 도구를 함께 쓸 수 있으며, 공유 여부나 Effect가 상태 전이 다음 단계인 것은 아닙니다.

DERIVED VALUE

Props·State로 계산 가능한가

필터 결과처럼 이미 가진 값에서 계산할 수 있으면 렌더 중 계산합니다. 값이 비싸다는 측정 근거가 있을 때 useMemo를 고려하며 실행 횟수를 보장하는 계약으로 쓰지 않습니다.

STATE TRANSITION

지역 사실과 전이를 저장

useState로 값을 저장합니다. 여러 사건이 관련 상태를 바꾸는 복잡한 전이는 useReducer로 모을 수 있습니다. reducer는 이 절의 실제 예제에는 없는 선택 확장입니다.

SHARING SCOPE

Context는 전달 범위

Provider가 소유한 state를 subtree에 전달할 수 있습니다. 본문 ThemeContext의 undefined guard는 Provider 없이 사용한 경우를 오류로 드러냅니다. Context 자체가 상태 저장 API는 아닙니다.

MUTABLE REFERENCE

ref 변경은 렌더를 요청하지 않는다

DOM 참조나 렌더에 쓰지 않는 가변값을 보관합니다. 본문 inputRef.current는 null 가능성을 검사한 뒤 focus합니다. 화면을 바꿀 값이면 ref만 갱신해서는 충분하지 않습니다.

EXTERNAL SYSTEM

Effect로 외부와 동기화

커밋 후 필요한 연결을 setup하고 수명에 맞춰 cleanup합니다. 상태 복잡도·공유 여부와 별개로 판단하며 클릭에 직접 대응하는 로직은 해당 이벤트 handler에서 처리할 수 있습니다.

REUSE LOGIC

커스텀 Hook은 로직을 재사용

각 useCounter 호출의 state는 독립됩니다. 예제는 increment의 상한·decrement의 하한만 적용하며 음수 step·외부 setCount까지 양쪽 범위 불변식을 강제하지 않습니다.

소유자와 공유 범위를 먼저 정하고 필요한 도구를 조합합니다. 같은 커스텀 Hook을 호출해도 하나의 state가 자동 공유되지는 않습니다.