icon
13장 : React와 타입스크립트

훅과 상태 관리


React Hooks는 React 16.8부터 도입된 기능으로, 함수형 컴포넌트에서 상태(state)와 생명주기(lifecycle) 기능을 사용할 수 있게 해줍니다. 이전에는 클래스형 컴포넌트에서만 가능했던 기능들을 함수형 컴포넌트에서 더 간결하고 재사용 가능한 방식으로 구현할 수 있게 되면서, React 개발의 패러다임을 크게 변화시켰습니다.

타입스크립트는 이러한 React Hooks와 완벽하게 통합되어, 상태 관리 및 부수 효과(side effects) 처리에 강력한 타입 안전성을 제공합니다. 이 절에서는 가장 일반적으로 사용되는 Hooks인 useState, useEffect, useContext, useRef, 그리고 사용자 정의 훅을 타입스크립트와 함께 사용하는 방법을 살펴보겠습니다.


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>((resolve) => {
        setTimeout(() => {
          if (userId === 1) {
            resolve({ id: 1, name: 'Alice', email: 'alice@example.com' });
          } else {
            // Reject를 통해 에러 시뮬레이션
            // throw new Error('User not found!');
            resolve(null as any); // TS 에러 방지용 임시
          }
        }, 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 훅과 타입스크립트

useEffect는 컴포넌트의 렌더링 이후에 부수 효과(side effects)를 수행할 때 사용됩니다. 데이터 가져오기, 구독 설정, 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);

  // 컴포넌트 마운트 시 한 번만 실행 (의존성 배열이 빈 배열[])
  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)');
      // 구독 해제, 타이머 클리어 등
    };
  }, []); // 빈 배열: 컴포넌트 마운트 시 한 번만 실행, 언마운트 시 클린업

  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;

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();
    }
  }, []); // 한 번만 실행

  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의 반환 값이 UseCounterReturn 인터페이스를 따르는지 검사하여 안정성을 높입니다.


요약

React Hooks는 함수형 컴포넌트에서 상태 관리 및 부수 효과를 처리하는 현대적이고 효율적인 방법을 제공합니다. 타입스크립트는 이러한 Hooks의 강력한 기능에 정적 타입 검사를 추가하여 다음과 같은 이점을 제공합니다.

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

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