훅
- 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
상태를 설정하지 않으면 코드의 나머지 부분에서 user
가 User
타입임을 가정하게 되며, 이는 런타임 오류로 이어질 수 있습니다.
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
useEffect
와 useLayoutEffect
는 둘 다 부작용을 수행하기 위해 사용되며, 선택적인 정리 함수를 반환합니다. 이는 반환 값으로 함수나 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를 확인하거나 의도적으로 타입 오류를 만들어 언어 서비스가 알려주게 할 수 있습니다.
옵션 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]
}
그러나 리액트 팀은 두 개 이상의 값을 반환하는 커스텀 훅은 튜플 대신 적절한 객체를 사용해야 한다고 권장합니다.
더 많은 훅 + 타입스크립트 읽을거리:
리액트 훅 라이브러리를 작성하는 경우 사용자가 사용할 수 있도록 타입도 함께 공개하는 것을 잊지 마세요.