10.2 Props와 State 타입 — useState<T>와 Props 설계
useState<T> 타입
기본 사용
import { useState } from 'react';
// 기본 타입 — 타입 추론 가능
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [active, setActive] = useState(false); // boolean
// 타입 명시 (초기값이 null이거나 복잡한 타입일 때)
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);
const [data, setData] = useState<ApiResponse<User> | undefined>(undefined);
복잡한 상태 타입
interface UserProfile {
id: string;
name: string;
email: string;
avatar?: string;
preferences: {
theme: 'light' | 'dark';
language: string;
notifications: boolean;
};
}
function ProfileEditor() {
const [profile, setProfile] = useState<UserProfile>({
id: '',
name: '',
email: '',
preferences: {
theme: 'light',
language: 'ko',
notifications: true,
},
});
// 중첩 상태 업데이트
const updateTheme = (theme: 'light' | 'dark') => {
setProfile(prev => ({
...prev,
preferences: {
...prev.preferences,
theme,
},
}));
};
return (
<div>
<input
value={profile.name}
onChange={e => setProfile(prev => ({ ...prev, name: e.target.value }))}
/>
</div>
);
}
useReducer로 복잡한 상태 관리
상태 로직이 복잡해지면 useReducer를 사용합니다.
import { useReducer } from 'react';
// 상태 타입
interface CartState {
items: CartItem[];
total: number;
isLoading: boolean;
}
// 액션 타입 (판별 유니온)
type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: string } // id
| { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
| { type: 'CLEAR_CART' }
| { type: 'SET_LOADING'; payload: boolean };
// 리듀서
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
return {
...state,
items: [...state.items, action.payload],
total: state.total + action.payload.price,
};
case 'REMOVE_ITEM':
const item = state.items.find(i => i.id === action.payload);
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
total: state.total - (item?.price ?? 0),
};
case 'CLEAR_CART':
return { ...state, items: [], total: 0 };
case 'SET_LOADING':
return { ...state, isLoading: action.payload };
default:
return state; // TypeScript가 완전성 검사
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
total: 0,
isLoading: false,
});
return (
<div>
{state.items.map(item => (
<div key={item.id}>
{item.name}
<button onClick={() => dispatch({ type: 'REMOVE_ITEM', payload: item.id })}>
삭제
</button>
</div>
))}
<p>합계: {state.total}원</p>
</div>
);
}
Props 타입 설계 패턴
기본값과 선택적 Props
interface SearchBarProps {
// 필수 props
onSearch: (query: string) => void;
// 선택적 props (기본값 있음)
placeholder?: string;
initialValue?: string;
debounceMs?: number;
disabled?: boolean;
}
function SearchBar({
onSearch,
placeholder = '검색어 입력...',
initialValue = '',
debounceMs = 300,
disabled = false,
}: SearchBarProps) {
const [query, setQuery] = useState(initialValue);
// 디바운스 처리
useEffect(() => {
const timer = setTimeout(() => onSearch(query), debounceMs);
return () => clearTimeout(timer);
}, [query, debounceMs, onSearch]);
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={placeholder}
disabled={disabled}
/>
);
}
PropsWithChildren
import { PropsWithChildren } from 'react';
// PropsWithChildren<P> = P & { children?: ReactNode }
type ModalProps = PropsWithChildren<{
isOpen: boolean;
onClose: () => void;
title: string;
}>;
function Modal({ isOpen, onClose, title, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2>{title}</h2>
<button onClick={onClose}>✕</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
}
콜백 Props 타입
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
onRowClick?: (row: T, index: number) => void;
onSort?: (column: keyof T, direction: 'asc' | 'desc') => void;
onPageChange?: (page: number, pageSize: number) => void;
renderEmpty?: () => React.ReactNode;
}
interface Column<T> {
key: keyof T;
header: string;
width?: number;
render?: (value: T[keyof T], row: T) => React.ReactNode;
}
function DataTable<T extends { id: string | number }>({
data,
columns,
onRowClick,
}: DataTableProps<T>) {
return (
<table>
<thead>
<tr>
{columns.map(col => (
<th key={String(col.key)}>{col.header}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, index) => (
<tr key={row.id} onClick={() => onRowClick?.(row, index)}>
{columns.map(col => (
<td key={String(col.key)}>
{col.render
? col.render(row[col.key], row)
: String(row[col.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
상태 끌어올리기와 Props 흐름
// 부모: 상태 소유
interface ParentState {
selectedId: string | null;
items: Item[];
}
function Parent() {
const [state, setState] = useState<ParentState>({
selectedId: null,
items: [],
});
const handleSelect = (id: string) => {
setState(prev => ({ ...prev, selectedId: id }));
};
return (
<div>
<ItemList
items={state.items}
selectedId={state.selectedId}
onSelect={handleSelect}
/>
{state.selectedId && (
<ItemDetail id={state.selectedId} />
)}
</div>
);
}
// 자식: Props로 받음
interface ItemListProps {
items: Item[];
selectedId: string | null;
onSelect: (id: string) => void;
}
function ItemList({ items, selectedId, onSelect }: ItemListProps) {
return (
<ul>
{items.map(item => (
<li
key={item.id}
className={item.id === selectedId ? 'selected' : ''}
onClick={() => onSelect(item.id)}
>
{item.name}
</li>
))}
</ul>
);
}
고수 팁
1. Partial<T>로 업데이트 함수 간소화
interface Settings {
theme: 'light' | 'dark';
fontSize: number;
language: string;
}
function useSettings() {
const [settings, setSettings] = useState<Settings>({
theme: 'light',
fontSize: 14,
language: 'ko',
});
// Partial로 일부만 업데이트
const updateSettings = (updates: Partial<Settings>) => {
setSettings(prev => ({ ...prev, ...updates }));
};
return { settings, updateSettings };
}
// 사용
updateSettings({ theme: 'dark' }); // fontSize, language는 유지
2. 불변 상태 업데이트에 Immer 활용
import { useImmer } from 'use-immer';
interface ComplexState {
users: User[];
selectedIds: Set<string>;
}
function Component() {
const [state, updateState] = useImmer<ComplexState>({
users: [],
selectedIds: new Set(),
});
const toggleUser = (id: string) => {
updateState(draft => {
if (draft.selectedIds.has(id)) {
draft.selectedIds.delete(id);
} else {
draft.selectedIds.add(id);
}
});
};
}