목차
18 장

hooks 타이핑 — useState · useReducer · useRef

빌트인 hook들의 타입 추론과 명시 시점. useReducer의 discriminated union 액션, useRef의 두 갈래(DOM ref와 값 보관), 그리고 React 19 ref-as-prop까지 다룹니다.

17장에서 컴포넌트 인터페이스를 타입으로 잡는 법을 봤습니다. 본 챕터에서는 컴포넌트 안쪽 — 빌트인 hook 들의 타입을 어떻게 다루는지를 정리합니다.

큰 원칙 한 줄로 시작합니다.

추론할 수 있으면 추론에 맡기고, 추론이 모자란 부분만 명시한다.

이 원칙이 모든 hook에 똑같이 적용됩니다. 17장의 discriminated union 모델은 본 챕터의 useReducer에서 다시 만납니다. 그리고 5장 / 13장 (커스텀 훅)의 JavaScript 패턴이 본 챕터에서 TypeScript 위에 다시 올려집니다.

useState — 초깃값으로 추론된다 #

가장 흔한 패턴부터.

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이라 제대로 동작하지 않습니다. 반드시 타입 인자로 가능한 모양을 명시 합니다.

null로 시작하는 상태
type User = { id: string; name: string };

const [user, setUser] = useState<User | null>(null);

setUser({ id: 'u1', name: '커티스' });   // OK
setUser(null);                            // OK (로그아웃)

이 패턴은 “데이터 로딩 전” 상태에 매우 흔합니다. 21장 (fetch와 API 응답 타이핑)에서 폼 상태로 더 자주 만나게 됩니다.

lazy initializer도 같은 규칙 #

함수로 초깃값을 만들 때도 마찬가지로 그 함수의 반환 타입이 그대로 상태 타입이 됩니다.

lazy init
const [todos, setTodos] = useState<Todo[]>(() => loadFromStorage());

loadFromStorage()의 반환 타입이 Todo[] 라면 타입 인자를 생략해도 됩니다. 다만 반환 타입이 모호하거나 unknown이면 명시하는 게 안전합니다.

useReducer — action을 좁히는 게 진짜 가치 #

useState가 단순 변경에 어울린다면, useReducer여러 종류의 변경을 한 곳에 모을 때 빛납니다. TypeScript와 만나면 action을 discriminated union으로 잡아 reducer 안에서 자연스럽게 좁혀집니다. 17장에서 짚은 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>
    </>
  );
}

핵심은 Actiondiscriminated union이라는 점입니다.

  • reducer의 switch 안에서 action.type === 'set'가지에서만 action.value가 보입니다.
  • dispatch({ type: 'set' })처럼 필요한 페이로드가 빠지면 컴파일 에러가 납니다.
  • dispatch({ type: 'unknown' }) 같은 오타도 잡힙니다.

이 안전망이 JavaScript로는 거의 불가능합니다. reducer를 쓰기 시작하면 TypeScript가 가장 빛나는 경우 중 하나입니다.

never로 exhaustiveness 검사 #

action을 추가했는데 reducer에서 처리를 빠뜨리면 어떻게 알아챌까요? default가지에서 never를 활용하면 컴파일 단계에서 잡힙니다.

exhaustiveness 검사
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는 사용 의도에 따라 두 가지 타이핑 패턴이 있습니다. 13장 (커스텀 훅)에서 잠깐 짚었던 ref의 두 갈래를 본 챕터에서 정확한 타입으로 굳히겠습니다.

1) DOM 노드 ref #

엘리먼트에 직접 붙이는 ref는 초깃값을 null로 두고, 타입 인자로 어떤 엘리먼트인지 알려 줍니다.

DOM ref
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를 씁니다. 이때는 초깃값을 그대로 줘서 추론에 맡깁니다.

값 보관 ref
function Timer() {
  const startedAt = useRef<number>(Date.now());
  // .current는 항상 number — null 검사 불필요

  return <span>{startedAt.current}</span>;
}

DOM ref와 달리 .currentnull 일 일이 없습니다. 초깃값 자체를 의미 있는 값으로 줬기 때문입니다.

용도초깃값.current 타입null 검사
DOM 노드nullT | null필요 (?.)
값 보관의미 있는 값그 값의 타입불필요

옛 자료에서는 두 용도를 구분하기 위해 MutableRefObject 같은 타입을 직접 쓰는 경우가 많았습니다. 요즘 @types/react는 초깃값으로 알아서 구분하므로 거의 신경 쓸 일이 없습니다.

ref를 prop으로 받기 (React 19) #

부모가 자식 컴포넌트의 DOM에 ref를 걸어야 할 때, 옛 모델에서는 forwardRef를 썼습니다. React 19부터는 그냥 ref를 prop처럼 받을 수 있어 더 간단해졌습니다.

ref를 prop으로 받기 (React 19)
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 자체는 여전히 동작하지만, 이 책은 새 코드에 위 패턴만 씁니다. forwardRef의 옛 코드를 ref-as-prop으로 옮기는 절차는 부록 A (옛 리액트 마이그레이션)에서 다룹니다. 28장 (React 19 신규 기능 정리)에서 본 모델의 변화 전체를 한 번 더 정리합니다.

useCallback — 시그니처를 보존한다 #

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로 추론됩니다. 명시할 필요가 없습니다.

이벤트 타입은 다음 19장 이벤트와 폼 타이핑에서 더 자세히 다룹니다. 지금은 “이벤트 핸들러 함수의 매개변수 타입은 핸들러 안에서 명시한다"만 기억하세요.

useMemo — 값의 타입은 그대로 추론된다 #

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을 만들 때 (13장 참조), 반환을 어떻게 할지가 종종 고민입니다. 정답은 없지만 다음 두 가지 가이드가 도움이 됩니다.

1) 두세 개 값이고 사용처에서 이름을 자유롭게 짓고 싶을 때 — 튜플

튜플 반환 + as const
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 모양과 일치하는 경우에만 튜플을 쓴다고 생각하면 무난합니다.

직접 해보기 #

useReducer로 작은 Todo 관리 hook을 TypeScript로 짜 봅니다.

src/useTodoReducer.ts:

src/useTodoReducer.ts
import { useReducer } from 'react';

type Todo = {
  id: string;
  text: string;
  done: boolean;
};

type State = {
  todos: Todo[];
};

type Action =
  | { type: 'add'; text: string }
  | { type: 'toggle'; id: string }
  | { type: 'remove'; id: string }
  | { type: 'clear' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add':
      return {
        todos: [
          ...state.todos,
          { id: crypto.randomUUID(), text: action.text, done: false },
        ],
      };
    case 'toggle':
      return {
        todos: state.todos.map((t) =>
          t.id === action.id ? { ...t, done: !t.done } : t
        ),
      };
    case 'remove':
      return { todos: state.todos.filter((t) => t.id !== action.id) };
    case 'clear':
      return { todos: [] };
    default: {
      const _exhaustive: never = action;
      return state;
    }
  }
}

export function useTodoReducer() {
  return useReducer(reducer, { todos: [] });
}

src/TodoApp.tsx:

src/TodoApp.tsx
import { useState } from 'react';
import { useTodoReducer } from './useTodoReducer';

function TodoApp() {
  const [{ todos }, dispatch] = useTodoReducer();
  const [text, setText] = useState('');

  return (
    <div style={{ padding: '16px', border: '1px solid #ccc', borderRadius: '8px' }}>
      <h2> </h2>
      <form onSubmit={(e) => {
        e.preventDefault();
        if (!text) return;
        dispatch({ type: 'add', text });
        setText('');
      }}>
        <input value={text} onChange={(e) => setText(e.target.value)} placeholder="할 일" />
        <button type="submit">추가</button>
        <button type="button" onClick={() => dispatch({ type: 'clear' })}>전체 삭제</button>
      </form>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <input type="checkbox" checked={t.done} onChange={() => dispatch({ type: 'toggle', id: t.id })} />
            <span style={{ textDecoration: t.done ? 'line-through' : 'none' }}>{t.text}</span>
            <button onClick={() => dispatch({ type: 'remove', id: t.id })}>삭제</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default TodoApp;

저장하고 동작을 확인해 보세요. 그 뒤 Action에 새 종류 { type: 'reorder'; from: number; to: number }를 추가하고 reducer에서 처리하지 않은 채 빌드해 보세요. _exhaustive: never 부분에서 빨간 줄이 뜨고, 빌드가 막힙니다.

연습문제 #

  1. useTodoReducerAction 한 종류 ({ type: 'rename'; id: string; text: string })를 추가하고, reducer에서 처리해 빌드가 다시 통과되도록 만들어 보세요. exhaustiveness 검사가 케이스 누락을 어떻게 잡는지 직접 손에 익혀 봅니다.
  2. useState<Status>('idle')Status 유니온을 'idle' | 'loading' | 'success' | 'error'로 만들고, <StatusBadge status={status} /> 컴포넌트에서 4개 상태를 모두 분기 처리해 보세요. 케이스를 빠뜨리면 빨간 줄이 어떻게 뜨는지 확인합니다 (never 패턴 활용).
  3. useRef의 두 용도 비교. <AutoFocusInput /> 컴포넌트에서 input에 ref를 걸어 마운트 시 포커스를 주고, 동시에 useRef<number>(Date.now())로 컴포넌트가 만들어진 시각을 기록해 화면에 표시해 보세요. 두 ref의 .current 타입이 어떻게 다른지 (HTMLInputElement | null vs number)에디터 호버로 확인합니다.

한 줄 요약: useState는 초깃값으로 추론. null 시작이거나 유니온 좁히기가 필요하면 타입 인자 명시. useReducer는 action을 discriminated union으로, never로 exhaustiveness 검사. useRef는 DOM 용 (null 시작)과 값 보관용 (의미 있는 초깃값) 두 패턴. React 19에서는 ref를 prop으로 직접 받을 수 있어 forwardRef가 거의 필요 없다. useCallback, useMemo는 추론을 신뢰. 커스텀 hook은 두 개면 as const 튜플, 그 이상은 객체.

다음 챕터 #

다음 19장 이벤트와 폼 타이핑에서는 이벤트 객체와 폼 입력에 어떤 타입을 쓰는지 다룹니다. 6장 (이벤트 핸들링)과 9장 (폼 다루기)의 JavaScript 패턴이 TypeScript 위에서 어떻게 더 안전해지는지, 그리고 27장 (Server Actions와 폼)에서 만날 FormData 타입의 토대까지 함께 짚습니다.

X