훅과 상태 관리
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 애플리케이션을 구축하는 데 필수적인 요소가 되었습니다.