ReactNextCentral

Published on
이 문서는 타입스크립트를 사용하여 리액트 훅을 효과적으로 활용하는 방법을 다루고 있습니다. 여기에는 useState, useCallback, useReducer, useEffect / useLayoutEffect, useRef 등의 기본 훅 사용법부터 커스텀 훅 만들기, 타입스크립트와 리액트 훅을 사용하는 라이브러리 예시까지 포함되어 있습니다.
Table of Contents

훅은 @types/react 버전 16.8부터 지원됩니다.

useState

간단한 값에 대해서는 타입 추론이 아주 잘 작동합니다.

const [state, setState] = useState(false);
// `state`는 boolean으로 추론됨
// `setState`는 boolean만 받음

복잡한 타입을 사용해야 할 때는 타입 추론 사용하기 섹션을 참고하세요.

그러나 많은 훅들이 null이나 유사한 기본값으로 초기화되는 경우가 있고, 타입을 제공하는 방법이 궁금할 수 있습니다. 타입을 명시적으로 선언하고 유니온 타입을 사용하세요:

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

// 나중에...
setUser(newUser);

설정 직후에 상태가 초기화되고 그 후에는 항상 값을 가지는 경우 타입 단언을 사용할 수도 있습니다.

const [user, setUser] = useState<User>({} as User);

// 나중에...
setUser(newUser);

이는 임시적으로 TypeScript 컴파일러에게 {}User 타입임을 "거짓말"하는 것입니다. user 상태를 설정하지 않으면 코드의 나머지 부분에서 userUser 타입임을 가정하게 되며, 이는 런타임 오류로 이어질 수 있습니다.

useCallback

useCallback은 다른 함수처럼 타입 지정이 가능합니다.

const memoizedCallback = useCallback(
  (param1: string, param2: number) => {
    console.log(param1, param2)
    return { ok: true }
  },
  [...],
);
/**
 * VSCode에서 다음과 같은 타입을 보여줍니다.
 * const memoizedCallback:
 *  (param1: string, param2: number) => { ok: boolean }
 */

리액트 18 미만에서는 useCallback의 타입 인자가 기본적으로 any[]로 지정되었습니다.

function useCallback<T extends (...args: any[]) => any>(
  callback: T,
  deps: DependencyList
): T;

리액트 18 이상에서는 useCallback의 함수 시그니처가 다음과 같이 변경되었습니다.

function useCallback<T extends Function>(
    callback: T, deps: DependencyList): T;

따라서, 다음 코드는 리액트 18 이상에서 "Parameter 'e' implicitly has an 'any' type." 오류를 발생시키지만 17 미만에서는 발생시키지 않습니다.

// @ts-expect-error 매개변수 'e'는 암묵적으로 'any' 타입을 가집니다.
useCallback((e) => {}, []);
// 명시적 'any' 타입.
useCallback((e: any) => {}, []);

useReducer

리듀서 액션에 식별된 유니온을 사용할 수 있습니다. 리듀서의 반환 타입을 정의하는 것을 잊지 마세요. 그렇지 않으면 타입스크립트가 추론하게 됩니다.

import { useReducer } from "react";

const initialState = { count: 0 };

type ACTIONTYPE =
  | { type: "increment"; payload: number }
  | { type: "decrement"; payload: string };

function reducer(state: typeof initialState, action: ACTIONTYPE) {
  switch (action.type) {
    case "increment":
      return { count: state.count + action.payload };
    case "decrement":
      return { count: state.count - Number(action.payload) };
    default:
      throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "decrement", payload: "5" })}>
        -
      </button>
      <button onClick={() => dispatch({ type: "increment", payload: 5 })}>
        +
      </button>
    </>
  );
}
redux의 Reducer와 함께 사용하기

redux 라이브러리를 사용해 리듀서 함수를 작성하는 경우, Reducer<State, Action> 형태의 편리한 헬퍼를 제공하여 반환 타입을 자동으로 처리해 줍니다.

그러므로 위의 리듀서 예제는 다음과 같이 됩니다.

import { Reducer } from 'redux';

export function reducer: Reducer<AppState, Action>() {}

useEffect / useLayoutEffect

useEffectuseLayoutEffect는 둘 다 부작용을 수행하기 위해 사용되며, 선택적인 정리 함수를 반환합니다. 이는 반환 값으로 함수나 undefined 외의 어떤 것도 반환하지 않아야 함을 의미하므로, 타입을 지정할 필요가 없습니다. useEffect를 사용할 때 함수나 undefined 이외의 것을 반환하지 않도록 주의해야 합니다. 그렇지 않으면 TypeScript와 리액트 모두 경고를 발생시킵니다. 이는 화살표 함수를 사용할 때 미묘할 수 있습니다.

function DelayedEffect(props: { timerMs: number }) {
  const { timerMs } = props;

  useEffect(
    () =>
      setTimeout(() => {
        /* 할 일 */
      }, timerMs),
    [timerMs]
  );
  // 나쁜 예! setTimeout은 중괄호로 함수 본문을 감싸지 않아 숫자를 암묵적으로 반환함
  return null;
}
위 예제의 해결 방안
function DelayedEffect(props: { timerMs: number }) {
  const { timerMs } = props;

  useEffect(() => {
    setTimeout(() => {
      /* 할 일 */
    }, timerMs);
  }, [timerMs]);
  // 더 나은 방법; void 키워드를 사용해 undefined를 반환하도록 확실히 함
  return null;
}

useRef

타입스크립트에서 useRef는 초기값을 완전히 커버하는지 여부에 따라 읽기 전용이거나 가변적인 참조를 반환합니다. 사용 사례에 맞는 옵션을 선택하세요.

옵션 1: DOM 요소 참조

DOM 요소에 접근하기 위해: 인자로 요소 타입만 제공하고 초기값으로 null을 사용하세요. 이 경우, 리액트가 관리하는 읽기 전용 .current를 가진 참조가 반환됩니다. 타입스크립트는 이 참조를 요소의 ref prop에 제공할 것을 기대합니다.

function Foo() {
  // - 가능하다면 가능한 한 구체적으로 지정하세요. 예를 들어, HTMLDivElement가
  //   HTMLElement보다 낫고 Element보다 훨씬 낫습니다.
  // - 기술적으로 이는 RefObject<HTMLDivElement>를 반환합니다.
  const divRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // ref.current가 null일 수 있습니다. 
    // 이는 ref-ed 요소를 조건부로 렌더링하거나 할당하는 것을
    // 잊었을 수 있기 때문에 예상된 것입니다.
    if (!divRef.current) throw Error("divRef가 할당되지 않았습니다.");

    // 이제 divRef.current는 확실하게 HTMLDivElement입니다
    doSomethingWith(divRef.current);
  });

  // 리액트가 관리할 수 있도록 요소에 ref를 제공하세요
  return <div ref={divRef}>등등</div>;
}

divRef.current가 결코 null이 아닐 것이라고 확신한다면, non-null 단언 연산자 !를 사용할 수도 있습니다.

const divRef = useRef<HTMLDivElement>(null!);
// 나중에... null인지 확인할 필요 없음
doSomethingWith(divRef.current);

여기서 타입 안전성을 포기하는 선택을 하고 있음을 유의하세요 - 렌더링에서 ref를 요소에 할당하는 것을 잊거나 ref-ed 요소가 조건부로 렌더링되는 경우 런타임 오류가 발생할 수 있습니다.

팁: 어떤 HTMLElement를 사용할지 선택하기

Ref는 구체성을 요구합니다 - 단순히 HTMLElement를 지정하는 것으로 충분하지 않습니다. 필요한 요소 타입의 이름을 모르겠다면, lib.dom.ts를 확인하거나 의도적으로 타입 오류를 만들어 언어 서비스가 알려주게 할 수 있습니다.

image

옵션 2: 가변 값 참조

가변 값을 가지려면: 원하는 타입을 제공하고 초기값이 그 타입에 완전히 속하는지 확인하세요:

function Foo() {
  // 기술적으로, 이는 MutableRefObject<number | null>을 반환합니다
  const intervalRef = useRef<number | null>(null);

  // 참조를 직접 관리합니다 (이것이 MutableRefObject라고 불리는 이유입니다!)
  useEffect(() => {
    intervalRef.current = setInterval(...);
    return () => clearInterval(intervalRef.current);
  }, []);

  // 참조는 어떤 요소의 "ref" prop에도 전달되지 않습니다
  return <button onClick={/* 참조에 있는 clearInterval 실행 */}>타이머 취소</button>;
}

또한 보세요

// Countdown.tsx

// forwardRef로 전달될 핸들 타입을 정의합니다
export type CountdownHandle = {
  start: () => void;
};

type CountdownProps = {};

const Countdown = forwardRef<CountdownHandle, CountdownProps>(
    (props, ref) => {
  useImperativeHandle(ref, () => ({
    // start()는 여기에서 타입 추론을 받습니다
    start() {
      alert("시작");
    },
  }));

  return <div>카운트다운</div>;
});
// Countdown 컴포넌트를 사용하는 컴포넌트

import Countdown, { CountdownHandle } from "./Countdown.tsx";

function App() {
  const countdownEl = useRef<CountdownHandle>(null);

  useEffect(() => {
    if (countdownEl.current) {
      // start()도 여기에서 타입 추론을 받습니다
      countdownEl.current.start();
    }
  }, []);

  return <Countdown ref={countdownEl} />;
}

또한 보세요:

커스텀 훅

커스텀 훅에서 배열을 반환하는 경우, 타입스크립트가 유니언 타입을 추론하는 것을 피하고 싶을 겁니다(실제로는 배열의 각 위치마다 다른 타입을 원합니다). 대신, TS 3.4 const 단언을 사용하세요:

import { useState } from "react";

export function useLoading() {
  const [isLoading, setState] = useState(false);
  const load = (aPromise: Promise<any>) => {
    setState(true);
    aPromise.finally(() => setState(false));
  };
  // [boolean, typeof load]로 추론됨, (boolean | typeof load)[] 대신
  return [isLoading, load] as const; 
}

타입스크립트 플레이그라운드에서 보기

이 방법을 사용하면, 구조 분해할 때 실제로 구조 분해 위치에 기반한 올바른 타입을 얻게 됩니다.

대안: 튜플 반환 타입 단언하기

const 단언에 문제가 있다면, 함수 반환 타입을 단언하거나 정의할 수도 있습니다:

import { useState } from "react";

export function useLoading() {
  const [isLoading, setState] = useState(false);
  const load = (aPromise: Promise<any>) => {
    setState(true);
    return aPromise.finally(() => setState(false));
  };
  return [isLoading, load] as [
    boolean,
    (aPromise: Promise<any>) => Promise<any>
  ];
}

커스텀 훅을 많이 작성하는 경우 튜플 타입을 자동으로 지정하는 헬퍼 함수도 유용할 수 있습니다:

function tuplify<T extends any[]>(...elements: T) {
  return elements;
}

function useArray() {
  const numberValue = useRef(3).current;
  const functionValue = useRef(() => {}).current;
  return [numberValue, functionValue]; // 타입은 (number | (() => void))[]
}

function useTuple() {
  const numberValue = useRef(3).current;
  const functionValue = useRef(() => {}).current;
  return tuplify(numberValue, functionValue); // 타입은 [number, () => void]
}

그러나 리액트 팀은 두 개 이상의 값을 반환하는 커스텀 훅은 튜플 대신 적절한 객체를 사용해야 한다고 권장합니다.

더 많은 훅 + 타입스크립트 읽을거리:

리액트 훅 라이브러리를 작성하는 경우 사용자가 사용할 수 있도록 타입도 함께 공개하는 것을 잊지 마세요.

예시 React Hooks + TypeScript 라이브러리:

추가할 내용이 있나요? 이슈를 등록하세요.