#2 props와 children 타이핑 에서 컴포넌트 인터페이스를 타입으로 잡는 법을 봤습니다. 이번 글에서는 컴포넌트 안쪽 — 빌트인 hook들의 타입을 어떻게 다루는지를 정리합니다.
큰 원칙 한 줄로 시작합시다.
추론할 수 있으면 추론에 맡기고, 추론이 모자란 자리만 명시한다.
이 원칙이 다섯 hook 모두에 똑같이 적용됩니다.
useState — 초깃값으로 추론된다
가장 흔한 패턴부터.
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
// count는 number, setCount는 (n: number | ((prev: number) => number)) => void
return (
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
);
}useState(0) 만으로 count: number 가 추론됩니다. setCount 도 자동으로 Dispatch<SetStateAction<number>> 로 잡혀서 setCount(1) 도, setCount((c) => c + 1) 도 모두 정확하게 동작해요.
초깃값이 좁게 추론되는 경우
리터럴 초깃값은 가끔 너무 좁게 추론됩니다.
const [status, setStatus] = useState('idle');
// 추론된 타입: string
setStatus('loading'); // OK
setStatus('done'); // OK
setStatus('foo'); // OK — 사실 의도와 다름'idle', 'loading', 'done' 만 허용하고 싶다면 타입 인자로 명시 해야 합니다.
type Status = 'idle' | 'loading' | 'done';
const [status, setStatus] = useState<Status>('idle');
setStatus('done'); // OK
setStatus('foo'); // ✗초깃값이 null 인 경우
초깃값으로 null 을 주면 추론은 그저 null 이라 제대로 동작하지 않습니다. 반드시 타입 인자로 가능한 모양을 명시 합니다.
type User = { id: string; name: string };
const [user, setUser] = useState<User | null>(null);
setUser({ id: 'u1', name: '커티스' }); // OK
setUser(null); // OK (로그아웃)이 패턴은 "데이터 로딩 전" 상태에 매우 흔합니다. 다음 글에서 폼 상태로 더 자주 만나게 돼요.
lazy initializer 도 같은 규칙
함수로 초깃값을 만들 때도 마찬가지로 그 함수의 반환 타입이 그대로 상태 타입이 됩니다.
const [todos, setTodos] = useState<Todo[]>(() => loadFromStorage());loadFromStorage() 의 반환 타입이 Todo[] 라면 타입 인자를 생략해도 됩니다. 하지만 반환 타입이 모호하거나 unknown 이면 명시하는 게 안전해요.
useReducer — action을 좁히는 게 진짜 가치
useState 가 단순 변경에 어울린다면, useReducer 는 여러 종류의 변경을 한 곳에 모을 때 빛납니다. 타입스크립트와 만나면 action을 discriminated union으로 잡아서 reducer 안에서 자연스럽게 좁혀집니다.
type State = {
count: number;
};
type Action =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'set'; value: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'set':
return { count: action.value }; // value가 여기서만 보임
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'set', value: 100 })}>=100</button>
</>
);
}핵심은 Action 이 discriminated union 이라는 점입니다.
- reducer의
switch안에서action.type === 'set'가지에서만action.value가 보입니다. dispatch({ type: 'set' })처럼 필요한 페이로드가 빠지면 컴파일 에러가 납니다.dispatch({ type: 'unknown' })같은 오타도 잡힙니다.
이 안전망이 자바스크립트로는 거의 불가능해요. reducer를 쓰기 시작하면 타입스크립트가 가장 빛나는 자리 중 하나입니다.
never로 exhaustiveness 검사
action을 추가했는데 reducer에서 처리를 빠뜨리면 어떻게 알아챌까요? default 가지에서 never 를 활용하면 컴파일 단계에서 잡힙니다.
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'set':
return { count: action.value };
default: {
const _exhaustive: never = action; // 새 action을 까먹으면 여기서 ✗
return state;
}
}
}Action 에 새 종류를 추가하는데 reducer에서 처리하지 않으면 _exhaustive: never 자리에서 빨간 줄이 나요. 케이스를 빠뜨릴 위험을 컴파일 단계가 막아 줍니다.
useRef — 두 가지 용도, 두 가지 타이핑
useRef 는 사용 의도에 따라 두 가지 타이핑 패턴이 있습니다.
1) DOM 노드 ref
엘리먼트에 직접 붙이는 ref는 초깃값을 null 로 두고, 타입 인자로 어떤 엘리먼트인지 알려 줍니다.
import { useRef, useEffect } from 'react';
function AutoFocusInput() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} type="text" />;
}inputRef.current 의 타입은 HTMLInputElement | null 입니다. 마운트 전에는 null 이라 옵셔널 체이닝(?.)이 필요해요.
2) 변경 가능한 값 보관
상태가 아닌 "값을 들고 있다가 다음 렌더에서도 같은 객체"가 필요할 때 — 예를 들어 setInterval ID, 이전 prop 기억 — 도 ref를 씁니다. 이때는 초깃값을 그대로 줘서 추론에 맡깁니다.
function Timer() {
const startedAt = useRef<number>(Date.now());
// .current는 항상 number — null 검사 불필요
return <span>{startedAt.current}</span>;
}DOM ref와 달리 .current 가 null 일 일이 없어요. 초깃값 자체를 의미 있는 값으로 줬기 때문입니다.
참고: 옛날에는 두 용도를 구분하기 위해
MutableRefObject같은 타입을 직접 쓰는 자료도 많았습니다. 요즘@types/react는 초깃값으로 알아서 구분하므로 거의 신경 쓸 일이 없어요.
forwardRef 의 ref 타이핑
부모가 자식 컴포넌트의 DOM에 ref를 걸어야 할 때 forwardRef 를 쓰는데, 리액트 19부터는 그냥 ref 를 prop처럼 받을 수 있어 더 간단해졌습니다.
import type { Ref } from 'react';
type InputProps = {
ref?: Ref<HTMLInputElement>;
placeholder?: string;
};
function Input({ ref, placeholder }: InputProps) {
return <input ref={ref} placeholder={placeholder} />;
}
// 부모에서
function Form() {
const inputRef = useRef<HTMLInputElement>(null);
return <Input ref={inputRef} placeholder="이름" />;
}forwardRef 자체는 여전히 동작하지만, 새로 짜는 코드는 위 패턴이 더 짧고 타입도 자연스럽습니다.
useCallback — 시그니처를 보존한다
useCallback 은 함수의 참조 동일성을 유지하기 위해 씁니다. 타입 관점에서는 추가로 할 일이 거의 없어요. 안에 넘긴 함수의 타입이 그대로 추론됩니다.
import { useCallback, useState } from 'react';
function SearchBar() {
const [query, setQuery] = useState('');
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
},
[]
);
return <input value={query} onChange={handleChange} />;
}handleChange 는 (e: React.ChangeEvent<HTMLInputElement>) => void 로 추론됩니다. 명시할 필요가 없어요.
이벤트 타입은 다음 글(#4 이벤트와 폼 타이핑)에서 더 자세히 다룹니다. 지금은 "이벤트 핸들러 함수의 매개변수 타입은 핸들러 안에서 명시한다"만 기억하세요.
useMemo — 값의 타입은 그대로 추론된다
useMemo 도 마찬가지입니다. 안에서 만든 값의 타입이 그대로 반환 타입이 됩니다.
import { useMemo } from 'react';
type Todo = { id: string; text: string; done: boolean };
function TodoStats({ todos }: { todos: Todo[] }) {
const stats = useMemo(() => {
return {
total: todos.length,
done: todos.filter((t) => t.done).length,
};
}, [todos]);
// stats: { total: number; done: number }
return <p>{stats.done} / {stats.total}</p>;
}추론을 신뢰해도 좋습니다. 명시는 거의 필요 없고, 필요하다면 useMemo<{ total: number; done: number }>(...) 처럼 타입 인자를 줄 수 있어요.
커스텀 hook — 반환 형태는 튜플 vs 객체
빌트인 hook을 조합해 커스텀 hook을 만들 때, 반환을 어떻게 할지가 종종 고민입니다. 정답은 없지만 다음 두 가지 가이드가 도움이 됩니다.
1) 두세 개 값이고 사용처에서 이름을 자유롭게 짓고 싶을 때 — 튜플
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const;
}
// 사용처에서 자유롭게 이름 지정
const [isOpen, toggleOpen] = useToggle();as const 가 핵심입니다. 그게 없으면 (boolean | (() => void))[] 로 추론되어 디스트럭처링 시 타입이 섞여 버려요. as const 를 붙이면 정확히 [boolean, () => void] 튜플이 됩니다.
2) 값이 네 개 이상이거나 의미가 분명한 이름이 있을 때 — 객체
function useTodos() {
const [items, setItems] = useState<Todo[]>([]);
const add = useCallback((text: string) => { /* ... */ }, []);
const remove = useCallback((id: string) => { /* ... */ }, []);
const toggle = useCallback((id: string) => { /* ... */ }, []);
return { items, add, remove, toggle };
}
// 사용처
const { items, add, remove } = useTodos();대부분의 비자명한 커스텀 hook은 객체 쪽이 읽기 좋습니다. 이름이 그대로 의미를 전달하니까요. useState, useReducer 처럼 두 개만 반환하는 빌트인 hook 모양과 일치하는 경우에만 튜플을 쓴다고 생각하면 무난합니다.
마무리
이번 글에서는 다음을 정리했습니다.
useState는 초깃값으로 추론.null시작이거나 유니온 좁히기가 필요하면 타입 인자 명시useReducer는 action을 discriminated union으로.never로 exhaustiveness 검사useRef는 DOM용(null시작)과 값 보관용(의미 있는 초깃값) 두 패턴- 리액트 19에서는
ref를 prop으로 직접 받을 수 있어forwardRef가 거의 필요 없음 useCallback,useMemo는 추론을 신뢰- 커스텀 hook은 두 개면
as const튜플, 그 이상은 객체
다음 글(#4 이벤트와 폼 타이핑)에서는 이벤트 객체와 폼 입력에 어떤 타입을 쓰는지, 그리고 제어/비제어 폼을 타입스크립트로 어떻게 잡는지를 다룹니다.