HOC도 쓰고 싶고, props도 쓰고 싶은 사람이 마주할 문제 두 가지

2023. 2. 20. 16:38React/고민거리들

HOC를 사용할 때 가장 어려운 점은 HOC로 부터 내려받는 props가 아니라, 다른 로직을 위한 props를 받는 경우이다.

(사실 HOC보다는 제네릭 사용으로 인해 생긴다고 생각한다.)

 

먼저, HOC를 위해 구현한 withLatestVersion.tsx를 보면서 어떻게 이 함수가 랩핑할 컴포넌트의 props를 추출하는 지 살펴보아야한다.

// withLatestVersion.tsx

import React, { useEffect, useState } from 'react';
import { DataState, LtsVersionType } from '../type/type';
import { useQuery } from '@apollo/client';
import { LOL_PATCH_VERSIONS } from '../type/api';

export interface WithSelectedVersionProps {
  result: DataState<string> | null;
}

const withLatestVersion = <P extends WithSelectedVersionProps>(Component: React.ComponentType<P>) => {
  
  // Omit을 통해 WithSelectedVersionProps의 result key를 제거한 props를 다룸.
  return function ComponentWithVersion(props : Omit<P, keyof WithSelectedVersionProps>) {
    const { data, error, loading } = useQuery<LtsVersionType>(LOL_PATCH_VERSIONS);
    const [result, setResult] = useState<DataState<string> | null>(null);
    console.debug(data, error, loading);

    useEffect(() => {
      setResult({ data: (data && data.latestVersion) ? data.latestVersion[0] : '', error, loading });
    }, [data, error, loading]);

    // result: DataState<string>, props as P: 원래 이 컴포넌트가 가진 Props
    return <Component {...result} {...props as P} />;
  }
}

export default withLatestVersion;

 

 

1. Omit을 통해 랩핑할 컴포넌트 props만 분리.

2. Component 인스턴스 리턴 시 HOC 로직에서 넣어줄 props와 원래 이 컴포넌트가 필요한 로직을 삽입.

 

먼저 문제를 생각하기 전에 지금 이 컴포넌트에선 3가지 주체가 존재한다.

이해를 위해 다음과 같은 상황을 가정해보자.

 

1. 엔드투엔드로 부모 컴포넌트에서 Draft.tsx 컴포넌트를 호출하려고 한다면, props로 콜백 함수를 하나 넣어줘야한다.
2. Draft에서 HOC로 랩핑할 때 Props를 제네릭에 넣어주도록 선언해야한다.

 

 

여기에서 발생하는 첫 번째 문제는 바로 Draft.tsx 컴포넌트의 Props는 어떻게 구성해야하는가?

약간 바꿔서 말해보면 HOC를 랩핑할 때 신경써야할 WithSelectedVersionProps를 어떻게 처리해야하는가?이다.

1. 제네릭 정의하기


// Draft.tsx

import React from 'react';
import withLatestVersion, { WithSelectedVersionProps } from '../../utils/withSelectedVersion';
import { useQuery } from '@apollo/client';
import { ChampionType, DataState } from '../../type/type';
import { ALL_CHAMPIONS } from '../../type/api';
import Scrollbars from 'react-custom-scrollbars-2';
import PickPortrait from '../Portrait/PickPortrait';
import DraftBG from '../../assets/draft_outline.png';

// WithSelectedVersionProps를 상속한 Props로 정의해야함.
interface Props extends WithSelectedVersionProps {
  portraitHandler?: (id: string) => void;
}

const ChampionPicks = ({ portraitHandler, ...result }: Props) => {
  //..
  return (
  //..
  )
}

// 만약 상속하지 않을 때는 여기서 에러 발생.
export default withLatestVersion<Props>(ChampionPicks);

 

Draft를 호출할 때 콜백하나만 넣어야하지 않나? 라고 가정하고 interface를 작성하게 되면 아래와 같은 에러 메시지를 확인하게 된다.

 

Type 'Props' does not satisfy the constraint 'WithSelectedVersionProps'.   Property 'result' is missing in type 'Props' but required in type 'WithSelectedVersionProps'

 

// withSelectedVersion.tsx

//..
return function ComponentWithVersion(props : Omit<P, keyof WithSelectedVersionProps>) {
//..
}

 

아까 로직분리를 위해 작성했던 Omit 함수에서 선언된 제네릭 P가 WithSelectedVersionProps의 상속을 가정하고 설계되었기 때문에 이런 에러가 발생했다. 내가 찾아낸 해결방법은 Draft에서 해당 interface를 상속하는 것이지만, 걱정했던 result를 props로 넣어야하는 일은 일어나지 않았다.

 

 

타입체커가 타입을 추론할 때, Omit되고 난 interface를 가정하기 때문이다.

사용할 때는 result를 신경쓰지 않고 필요한 props만 넣어주면 된다. 실제로 코드 자동완성에서도 걸러진다.

 

2. 상속의 나비효과: HOC로 받는 props 처리방법


상속으로 선언한 interface로 인해 type이 엉키는 문제가 여기에서 일어나는데 먼저 상황을 봐야한다.

 

// withSelectedVersion.tsx

//..
return function ComponentWithVersion(props : Omit<P, keyof WithSelectedVersionProps>) {
    const { data, error, loading } = useQuery<LtsVersionType>(LOL_PATCH_VERSIONS);
    const [result, setResult] = useState<DataState<string> | null>(null);
    console.debug(data, error, loading);

    useEffect(() => {
      setResult({ data: (data && data.latestVersion) ? data.latestVersion[0] : '', error, loading });
    }, [data, error, loading]);
    
    // result를 스프레드 연산자로 풀어줬다. DataState의 속성들이 들어간다.
    return <Component {...result} {...props as P} />;
  }
//..

 

자, Component에 result를 스프레드 연산자로 넣어줬기 때문에 result: {} 꼴이 아니라 풀어져서 삽입된다는 점에 주목해야한다. 실제로 콘솔도 아래와 같이 출력된다.

 

 

그런데, Draft 컴포넌트의 타입체커가 이상하다. result가 풀려서 나온다고 추론하지 못한다.

 

result.result.. 정말 이상한 코드 등장

 

내가 찾아낸 해결방법은 타입 캐스팅을 상단에서 다시 해주는 방법이지만, 그다지 좋은 방법은 아니다.

그냥 캐스팅해주면 속성이 없다고 나오기 때문에 unknown을 거쳐서 캐스팅 해줘야한다.

 

const ChampionPicks = ({ portraitHandler, ...result }: Props) => {
  const { data, error, loading } = result as unknown as DataState<string>;
  //..

 

3. 정리


 

HOC를 사용하면서 props를 따로 사용하고 싶은 경우에 사용하는 방법을 다룬다.

 

목표 1. 선언 시에 HOC와 관련된 props가 보이게 하지 않기.

목표 2. HOC에 랩핑되는 컴포넌트의 interface 전달해주기.

 

1. withXXXX.tsx (HOC 컴포넌트)

  • HOC 순수함수에 props를 제네릭으로 받는다.
  • 받은 제네릭을 Omit 함수를 통해 컴포넌트의 props와 HOC로 보내줄 props를 분리한다.
    (이 시점에서 선언 시에 hoc 관련 props는 보이지 않게 된다.)

 

2. 자식컴포넌트.tsx

  • interface 설계 시 HOC의 interface를 상속한다. (편의상 Props로 지칭)
  • export default 선언부에 Generic으로 Props를 넣어준다.
  • 타입 버그가 있기 때문에, HOC로 받는 타입은 as unknwon as DataState<string>으로 캐스팅해준다.
    (DataState는 HOC interface의 result를 풀어놓은 interface이다.)

 

글솜씨가 많이 부족하기도 하고, 잘못 결론 지은 부분이 있을 수 있습니다.

잘못된 부분은 댓글로 수정요청 주시면 반영해놓겠습니다.

긴 글 읽어주셔서 감사합니다. :)