📖이 글은 타입스크립트 스터디를 진행하면서 우아한 타입스크립트 with 리액트의 타입 내용을 정리한 글입니다.
핵심 개념, 느낀 점 등 간단하게 정리했습니다.

9. 훅(Hook)

리액트 훅 도입의 배경과 필요성

  • 기존 클래스 컴포넌트의 한계
    • 상태 로직 재사용이 어려움
      • 각 컴포넌트에서 비슷한 상태 로직을 직접 작성
      • render props나 고차 컴포넌트(HOC, Higher Order Component)로 재사용이 가능 ⇒ 근본적인 해결책 아님
    • 생명주기 메서드의 복잡성
      • componentDidMount, componentDidUpdate 등에 서로 관련 없는 로직이 섞여 코드 복잡성 증가
      • 모든 상태 관련 로직을 생명주기 메서드에 넣음
  • 이를 해결하기 위해 React 16.8부터 훅(Hooks) 도입
    • 훅의 특징
      • 함수형 컴포넌트에서 클래스 컴포넌트의 생명주기와 유사한 로직 실행 가능
      • 상태 및 사이드 이펙트를 더 깔끔하게 처리 가능
      • 커스텀 훅을 통해 상태 로직을 재사용할 수 있음
      • 결과적으로 컴포넌트의 복잡도를 낮추는 데 기여

React Component Lifecycle

순서: TriggerRenderCommitBrowser Paint(실제 렌더링)

Trigger: 초기 root.render() 호출 또는 state·props 변경으로 렌더 요청이 큐(React 내부에 있는 업데이트 대기열)에 들어감
Render: React가 컴포넌트 함수를 호출해 가상 DOM을 계산하는 순수 단계
Commit: 계산된 차이를 실제 DOM 수정(삽입·갱신·제거)
Browser Paint(실제 렌더링): DOM이 수정된 뒤 브라우저가 화면을 갱신

 

 

9.1 리액트 훅

훅이 추가되기 이전에는 클래스 컴포넌트에서만 상태를 가질 수 있었음.

클래스 컴포넌트에는 하나의 생명주기 함수에서만 상태 업데이트에 따른 로직 실행 가능

ex) 아래 예시 코드에서 componentDidMount, componentDidUpdate

 

프로젝트 규모가 커지면 상태를 스토어에 연결하거나 비슷한 로직을 가진 상태 업데이트 및 사이드 이펙트 처리가 불편함.

모든 상태를 하나의 함수 내에서 처리하면서 생긴 문제점

  • 관심사가 섞임
    서로 다른 목적의 코드가 같은 함수·파일·블록 안에 뒤섞여 있는 상태
  • 상태에 따른 테스트나 잘못 발생한 사이드 이펙트의 디버깅이 어려움
예시 코드
  // 마운트 때 현재 페이지 업데이트 및 네비게이션 이벤트 추가
  componentDidMount() {
    // 현재 화면 이름 저장 코드로 예상
    this.props.updateCurrentPage(routeName);

    // 포커스 이벤트 리스너 등록
    this.props.navigation.addListener('focus', () => {
        /*
          add focus handler to navigation
        */
      });

    // blur 이벤트 리스너 등록
    this.didBlurSubscription =
      this.props.navigation.addListener('blur', () => {
        /*
          add blur handler to navigation
        */
      });
  }

  // 언마운트 때 이벤트 리스너·타이머 해제
  componentWillUnmount() {
    // 마운트 때 저장해 둔 포커스 이벤트 리스너 해지
    if (this.didFocusSubscription != null) {
      this.didFocusSubscription();
    }

    // 마운트 때 저장해 둔 블러 이벤트 리스너 해지
    if (this.didBlurSubscription != null) {
      this.didBlurSubscription();
    }

    // _screenCloseTimer 타이머가 있으면 취소 후 참조 제거
    if (this._screenCloseTimer != null) {
      clearTimeout(this._screenCloseTimer);
      this._screenCloseTimer = null; // 재호출·메모리 누수 방지
    }
  }

  // 리렌더 때 관심 prop 변화에 따른 사이드 이펙트 처리
  componentDidUpdate(prevProps) {
    // 이전 props(prevProps)와 현재 props(this.props)를 비교하며 필요한 사이드 이펙트를 실행

    // 가드(early return) 조건
    if (this.props.currentPage != routeName) return;
    // 다른 화면일 때 건너뜀 ➔ 불필요한 연산·사이드 이펙트를 방지하는 성능 최적화

    // 에러 응답 비교 ➔ 에러 처리 관련으로 예상
    if (this.props.errorResponse != prevProps.errorResponse) {
      /*
      handle error response
      */
    }
    // 로그아웃 응답 비교 ➔ 로그아웃 후처리 관련으로 예상
    else if (this.props.logoutResponse != prevProps.logoutResponse) {
      /*
        handle logout response
      */
    }
    // navigateByType 비교 ➔ 페이지 관련으로 예상
    else if (this.props.navigateByType != prevProps.navigateByType) {
      /*
        handle navigateByType change
      */
    }

    // Handle other prop changes here
  }

componentWillUnmount에서는 componentDidMount에서 정의한 컴포넌트가 DOM에서 해제될 때 실행되어야 할 여러 사이드 이펙트 함수를 호출

 

componentWillUnmount에서 실행되어야 할사이드 이펙트가 하나 빠졌다면 componentDidMount와 비교해가며 어떤 함수가 빠졌는지 찾아야 함

 

props 변경에 대한 디버깅을 수행하려면 componentDidMount에서 원하는 props 변경 조건문이 나올 때까지 코드 찾아야 함

 

 

1) useState

useState는 상태관리를 휘한 훅

 

useState 타입 정의

// S : 상태(state) 값의 타입, 호출 시 넘긴 초기값의 타입이 그대로 S가 됨
function useState<S>(
  initialState: S | (() => S)
  // S       : 값 자체를 넘기면 그대로 초기 상태
  // () => S : 함수로 넘기면 첫 렌더 때만 실행해 반환값을 초기 상태로 사용

  // [지금 상태 값, 상태를 바꾸는 함수]
): [S, Dispatch<SetStateAction<S>>];

// A(=SetStateAction<S>)를 인자로 받고 반환값 없음(void)
type Dispatch<A> = (value: A) => void;

// setState에 값을 직접 대입하거나 업데이터 함수를 통해 넣을 수 있음.
// (prevState: S) => S : 이전 상태(prevState)를 받아 새 상태를 계산할 때 사용
type SetStateAction<S> = S | ((prevState: S) => S);

현재 상태상태를 바꾸는 함수 한 쌍을 반환함.

상태를 바꾸는 함수는 새 값이나 이전 값을 받아 새 값을 계산하는 함수 둘 다 받을 수 있음

 

useState 타입스트립트 사용 전 예시
import { useState } from "react";

const MemberList = () => {
  const [memberList, setMemberList] = useState([
    {
      name: "KingBaedal",
      age: 10,
      },
    {
      name: "MayBaedal",
      age: 9,
    },
  ]);

  // 🚨 addMember 함수를 호출하면 sumAge는 NaN이 된다
  const sumAge = memberList.reduce((sum, member) => sum + member.age, 0);

  const addMember = () => {
    setMemberList([
      ...memberList,
      {
        name: "DokgoBaedal",
        agee: 11,
      },
    ]);
  };
};
useState 타입스크립트 사용 후 예시
  import { useState } from "react";

  interface Member {
    name: string;
    age: number;
  }

  const MemberList = () => {
    const [memberList, setMemberList] = useState<Member[]>([]);

    // member의 타입이 Member 타입으로 보장된다
    const sumAge = memberList.reduce((sum, member) => sum + member.age, 0);

    const addMember = () => {
    // 🚨 Error: Type ‘Member | { name: string; agee: number; }’
    // is not assignable to type ‘Member’
      setMemberList([
        ...memberList,
        {
          name: "DokgoBaedal",
          agee: 11,
        },
      ]);
    };

    return (
      // ...
    );
  };

memberList에 새로운 멤버 객체를 추가할 때

agee라는 잘못된 속성이 포함된 객체로 인해 변수가 NaN이 되는 사이드 이펙트 발생

 

타입스크립트에서는 setMemberList의 호출 부분에서 추가하려는 새 객체의 타입을 확인하여 커파일타임에 타입 에러 발견 가능

 

 

2) 의존성 배열을 사용하는 훅

의존성 배열(Dependency Array)
함수의 코드 내부에서 참조되는 모든 반응형 값들이 포함된 배열로 구성
반응형 값에는 props와 state, 모든 변수 및 컴포넌트 body에 직접적으로 선언된 함수들이 포함됨

  • undefined(의존성 배열을 전달 X): 매 렌더마다 effect 실행
  • [](빈 의존성 배열): 마운트 1회 + 언마운트 시 cleanup 1회
  • [a, b](의존성 명시): a 또는 b 값이 바뀔 때마다 실행

 

 

useEffect와 useLayoutEffect

  • useEffectuseEffect 타입 정의
function useEffect(
  effect: EffectCallback, // DOM 커밋 이후 실행할 함수(EffectCallback)
  deps?: DependencyList // 의존성 배열(DependencyList)로 생략 가능
): void;

type DependencyList = ReadonlyArray<any>; // 읽기 전용 배열(ReadonlyArray<any>)

type EffectCallback = () => void | Destructor; // useEffect에 넘기는 함수의 타입.
// void : 정리(clean-up) 불필요
// Destructor : 정리 함수
  • useEffect: 렌더링 이후 리액트 함수 컴포넌트에 어떤 일을 수행해야 하는지 알려주기 위해 활용
    • EffectCallback
      • Destructor 반환 또는 아무것도 반환하지 않는 함수
      • Promise 타입은 반환하지 않음 -> 콜백 함수에는 비동기 함수 X
      • 비동기 함수를 호출할 수 있다면 경쟁 상태(Race Condition)를 불러일으킬 수 있음
경쟁 상태(Race Condition)
여러 프로세스/스레드가 공유된 데이터를 읽고 쓰는 작업을 할 때 실행 순서에 따라서 잘못된 값을 읽거나 쓰게 되는 상황
출처: 나무위키
    • deps
      • 옵셔널하게 제공
      • effect가 수행되기 위한 조건 나열
      • deps가 변경 여부를 얕은 비교로만 판단
        실제 값이 바뀌지 않더라도 참조 값이 변경되면 콜백 함수 실행
        ➔ 실제로 사용하는 값을 useEffect의 deps에서 사용해야 함.
얕은 비교(shallow compare)
객체나 배열과 같은 복합 데이터 타입의 값을 비교할 때 내부의 각 요소나 속성을 재귀적으로 비교하지 않고, 해당 값들의 참조나 기본 타입 값만으로 간단하게 비교하는 것
  • Destructor(클린업 함수)
    • 컴포넌트가 마운트 해제될 때 실행하는 함수
// 예시 코드
  useEffect(() => {
    // effect 본문으로 구독·타이머·DOM 조작 등 사이드 이펙트 실행
    function onResize() {
      console.log(window.innerWidth);
    }
    window.addEventListener("resize", onResize);

    // 정리(클린업) 함수
    return () => {
      // effect를 되돌려 놓거나 해제하는 로직
      window.removeEventListener("resize", onResize);
    };
  }, []);
클린업(Cleanup) 함수
useEffect나 useLayoutEffect와 같은 리액트 훅에서 사용되며, 컴포넌트가 해제되기 전에 정리(Clean up) 작업을 수행하기 위한 함수
  • useLayoutEffectuseEffect 타입 정의
type DependencyList = ReadonlyArray<any>; // 읽기 전용 배열(ReadonlyArray<any>)

function useLayoutEffect(
  effect: EffectCallback, // DOM 커밋 이후 실행할 함수(EffectCallback)
  deps?: DependencyList // 의존성 배열(DependencyList)로 생략 가능
): void;
  • useEffect와 비슷한 역할을 함.
  • 타입 정의는 useEffect와 동일하나 역할 차이 존재
  • 레이아웃 배치와 화면 렌더링이 모두 완료된 후에 실행
    // useLayoutEffect 사용
    const [name, setName] = useState("");
    
    // DOM이 커밋된 직후(페인트 전에) 동기적으로 실행
    useLayoutEffect(() => {
      // 시간이 오래 걸리는 작업이 끝났다고 가정
      setName("배달이");
    }, []);
    
    return <div>{`안녕하세요, ${name}님!`}</div>;
    name을 지정하는 setName이 오랜 시간이 걸린 후에 실행된다면 사용자는 빈 이름을 오랫동안 보고 있어야 함
    useLayoutEffect를 사용하면 화면에 해당 컴포넌트가 그려지기 전에 콜백 함수를 실행
    ➔ 첫 번째 렌더링 때 빈 이름이 뜨는 것을 방지할 수 있음.
// useEffect 사용
const [name, setName] = useState("");

useEffect(() => {
  // 매우 긴 시간이 흐른 뒤 아래의 setName()을 실행한다고 가정
  setName("배달이");
}, []);

return <div>{`안녕하세요, ${name}님!`}</div>;

 

 

useMemo와 useCallback

useMemo와 useCallback 모두 이전에 생성된 값 또는 함수를 기억하며, 동일한 값과 함수를 반복해서 생성하지 않도록 해주는 훅

어떤 값을 계산하는 데 오랜 시간이 걸릴 때나 렌더링이 자주 발생하는 form에서 주로 사용

type DependencyList = ReadonlyArray<any>;

// 값을 캐싱함
function useMemo<T>(
  factory: () => T, // 값을 계산하는 함수, 의존성이 변하지 않는 한 다시 실행되지 않고 이전 반환값을 재사용
  deps: DependencyList | undefined
): T;

// 함수 참조를 캐싱
function useCallback<T extends (...args: any[]) => any>(
  callback: T, // 실제로 컴포넌트가 이벤트 핸들러 등으로 넘길 함수
  deps: DependencyList
): T; // T: 참조가 고정된 새 함수, 기존 함수 중 하나

불필요한 곳에서 사용하지 않도록 해야 함. 과도하게 메모이제이션하면 컴포넌트의 성능 향상이 보장되지 않음.

메모이제이션(Memoization)
이전에 계산한 값을 저장함으로써 같은 입력에 대한 연산을 다시 수행하지 않도록 최적화하는 기술

 

 

3) useRef

리액트 애플리케이션에서 <input /> 요소에 포커스를 설정하거나 특정 컴포넌트의 위치로 스크롤 하는 등 DOM을 직접 선택해야 하는 경우 사용

import { useRef } from "react";

const MyComponent = () => {
  const ref = useRef < HTMLInputElement > null;
  // 제네릭에 HTMLInputElement만 넣음 ➔

  const onClick = () => {
    ref.current?.focus();
  };

  return (
    <>
      <button onClick={onClick}>ref에 포커스!</button>
      <input ref={ref} />
    </>
  );
};

export default MyComponent;

useRef 타입 정의

// 1번째 타입 정의: 초기값이 null 이외의 실제 값일 때 → current가 그 값으로 초기화되고, 이후 변경 가능
function useRef<T>(initialValue: T): MutableRefObject<T>;

// 2번째 타입 정의: 초기값으로 null을 넘길 때 → DOM 엘리먼트 참조처럼 나중에 React가 채움
function useRef<T>(initialValue: T | null): RefObject<T>;

// 3번째 타입 정의: 인자를 아예 생략할 때 → current 초깃값은 undefined, 이후 마음대로 바꿀 수 있는 Mutable 버전
function useRef<T = undefined>(): MutableRefObject<T | undefined>;

// 컴포넌트 코드에서 자유롭게 값을 대입 가능
interface MutableRefObject<T> {
  current: T;
}

interface RefObject<T> {
  readonly current: T | null;
}

useRef는 MutableRefObject 또는 RefObject 반환

 

MutableRefObject의 current는 값을 변경할 수 있음.

null 허용을 위해 useRef의 제네릭에 HTMLInputElement | null 타입 사용시 1번째 타입을 따름

이 때 MutableRefObject의 current는 변경할 수 있는 값이 되어 ref.current의 값이 바뀌는 사이드 이펙트가 발생할 수 있음.

 

RefObject의 current는 readonly로 값을 변경할 수 없음.

HTMLInputElement을 넣고, 인자에 null을 넣어 useRef의 2번째 타입 정의를 따름.

이 때 Ref를 반환하여 ref.current 값을 임의로 변경할 수 없게 됨.

 

 

자식 컴포넌트에 ref 전달하기

기본 HTML 요소가 아닌 리액트 컴포넌트에 ref를 전달할 수 있음.

import { useRef } from "react";

const Component = () => {
  const ref = useRef<HTMLInputElement>(null);
  return <MyInput ref={ref} />;
};

interface Props {
  ref: RefObject<HTMLInputElement>;
}

/**
  🚨 Warning: MyInput: `ref` is not a prop. Trying to access it will result in
  `undefined` being returned
  If you need to access the same value within the child component, you should pass
  it as a different prop
  */
const MyInput = ({ ref }: Props) => {
  return <input ref={ref} />;
};

ref 속성은 리액트에서 DOM 요소 접근이라는 특수한 목적으로 사용되기 때문에 prop를 넘겨주는 방식으로 전달할 수 없음.

리액트 컴포넌트에서 ref를 prop으로 전달하기 위해서 forwardRef를 사용해야 함.

ref가 아닌 inputRef 등의 다른 이름을 사용한다면 forwardRef를 사용하지 않아도 됨.

interface Props {
  name: string;
}

const MyInput = forwardRef<HTMLInputElement, Props>((props, ref) => {
  return (
    <div>
      <label>{props.name}</label>
      <input ref={ref} />
    </div>
  );
});

forwardRef의 두 번째 인자에 ref를 넣어 자식 컴포넌트로 ref를 전달할 수 있음.

// forwardRef 타입 정의
function forwardRef<T, P = {}>(
  render: ForwardRefRenderFunction<T, P>
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>>;

// ForwardRefRenderFunction 타입 정의
interface ForwardRefRenderFunction<T, P = {}> {
  (props: P, ref: ForwardedRef<T>): ReactElement | null;
  displayName?: string | undefined;
  defaultProps?: never | undefined;
  propTypes?: never | undefined;
}

type ForwardedRef<T> =
  | ((instance: T | null) => void)
  | MutableRefObject<T | null>
  | null;
  • forwardRef
    • 두 번째 인자를 통해 자식 컴포넌트에 fef를 전달
  • ForwardRefRenderFunction
    • 매개변수 T: ref로 전달하려는 요소의 타입
    • 매개변수 P: 일반적인 props 타입
  • ForwardedRef
    • MutableRefObject<T | null>:
      MutableRefObjectRefObject보다 넓은 범위의 타입을 가짐
      부모 컴포넌트의 ref 타입 정의와 관계없이 자식 컴포넌트에서 해당 ref 수용 가능

 

 

useImperativeHandle

ForwardRefRenderFunction과 함께 쓸 수 있는 훅

부모 컴포넌트는 ref를 통해 자식 컴포넌트에서 정의한 커스터마이징된 메서드 호출

자식 컴포넌트는 내부 상태나 로직 관리 및 부모 컴포넌트와의 결합도 낮춤

/* 자식 컴포넌트 */
// <form> 태그의 submit 함수만 따로 뽑아와서 정의한다
type CreateFormHandle = Pick<HTMLFormElement, "submit">;

type CreateFormProps = {
  defaultValues?: CreateFormValue;
};

const JobCreateForm: React.ForwardRefRenderFunction<
  CreateFormHandle,
  CreateFormProps
> = (props, ref) => {
  // useImperativeHandle Hook을 사용해서 submit 함수를 커스터마이징한다
  useImperativeHandle(ref, () => ({
    submit: () => {
      /* submit 작업을 진행 */
    },
  }));

  // ...
};

/* 부모 컴포넌트 */
const CreatePage: React.FC = () => {
  // `CreateFormHandle` 형태를 가진 자식의 ref를 불러온다
  const refForm = useRef<CreateFormHandle>(null);

  const handleSubmitButtonClick = () => {
    // 불러온 ref의 타입에 따라 자식 컴포넌트에서 정의한 함수에 접근할 수 있다
    refForm.current?.submit();
  };

  // ...
};

자식 컴포넌트는 부모 컴포넌트에서 호출할 수 있는 함수 생성 가능

부모 컴포넌트는 current.submit() 사용하여 자식 컴포넌트의 특정 메서드 실행 가능

 

 

useRef의 여러 가지 특성

  • 자식 컴포넌트 저장하는 변수로 활용
  • 상태가 변경되더라도 불필요한 렌더링 피하기
    • useRef로 관리되는 변수는 값이 바뀌어도 리렌더링 발생하지 않음
  • 빠른 상태 조회
    • 리액트 컴포넌트의 상태는 상태 변경 함수 호출하고 렌더링된 이후 업데이트된 상태 조회 가능
      useRef로 관리되는 변수는 값을 설정한 후 즉시 조회 가능
type BannerProps = {
  autoplay: boolean; // 자동 재생 기능을 활성화할지 여부
};

const Banner: React.FC<BannerProps> = ({ autoplay }) => {
  const isAutoPlayPause = useRef(false);

  if (autoplay) {
    // keepAutoPlay 같이 isAutoPlay가 변하자마자 사용해야 할 때 쓸 수 있다
    const keepAutoPlay = !touchPoints[0] && !isAutoPlayPause.current;

    // ...
  }
  return (
    <>
      {autoplay && (
        <button
          aria-label="자동 재생 일시 정지"
          /*
          isAutoPlayPause는 사실 렌더링에는 영향을 미치지 않고 로직에만 영향을 주므로
          상태로 사용해서 불필요한 렌더링을 유발할 필요가 없다
          */
          onClick={() => {
            isAutoPlayPause.current = true;
          }}
        />
      )}
    </>
  );
};

const Label: React.FC<LabelProps> = ({ value }) => {
  useEffect(() => {
    // value.name과 value.id를 사용해서 작업한다
  }, [value]);

  // ...
};

isAutoPlayPause는 현재 자동 재생이 일시 정지되었는지 확인하는 ref 변수임.

이 변수는 렌더링에 영향 X, 값이 변경되더라도 다시 렌더링을 기다릴 필요 없이 사용할 수 있어야 함.

isAutoPlayPause.current에 null이 아닌 값을 할당해서 변수처럼 활용 가능

훅의 규칙

  • 훅은 항상 최상위 레벨에서 호출 ➔ 훅의 상태를 올바르게 유지 가능
  • 훅은 항상 함수 컴포넌트나 커스텀 훅 등의 리액트 컴포넌트 내에서만 호출

 

 

9.2 커스텀 훅

1) 나만의 훅 만들기

사용자 정의 훅을 생성하여 컴포넌트 로직을 함수로 뽑아내 재사용할 수 있음.

  • 커스텀 훅(custom hook) 특징
    • 리액트 컴포넌트 내에서만 사용할 수 있음.
    • use로 시작해야 함.
/* onChange 함수를 input 값과 함께 반환하는 훅(useInput.js) */

import { useState } from "react";

// 초기값을 useState로 관리
const useInput = (initialValue) => {
  const [value, setValue] = useState(initialValue);

  const onChange = (e) => {
    setValue(e.target.value);
  };

  return { value, onChange };
};

 

 

2) 타입스크립트로 커스텀 훅 강화하기

/* 확장자명을 js에서 ts로 변경(useInput.ts) */

import { useState, useCallback } from "react";

// 🚨 Parameter ‘initialValue’ implicitly has an ‘any’ type.ts(7006)
const useInput = (initialValue) => {
  const [value, setValue] = useState(initialValue);

  // 🚨 Parameter ‘e’ implicitly has an ‘any’ type.ts(7006)
  const onChange = useCallback((e) => {
    setValue(e.target.value);
  }, []);

  return { value, onChange };
};

export default useInput;

js에서 ts로만 바꾸게 되면 onChange의 e 인자 타입이 지정되지 않아 에러 발생

 

import { useState, useCallback, ChangeEvent } from "react";

// ✅ initialValue에 string 타입을 정의
const useInput = (initialValue: string) => {
  const [value, setValue] = useState(initialValue);

  // ✅ 이벤트 객체인 e에 ChangeEvent<HTMLInputElement> 타입을 정의
  const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    setValue(e.target.value);
  }, []);

  return { value, onChange };
};

export default useInput;

적절한 타입을 지정하여 에러 해결 가능.

이벤트 객체 e와 같이 타입 유추가 어려울 때 IDE를 활용하면 타입스크립트 컴파일러가 현재 사용하고 있는 타입을 유추해서 알려줌

반응형

+ Recent posts