React - Higher Order Component

이소라·2022년 8월 30일
0

React

목록 보기
11/23

Higher-Order Component(HOC)

  • 고차 컴포넌트(HOC) : 컴포넌트를 인수로 받아서 새로운 컴포넌트를 반환하는 함수
const EnhancedComponent = higherOrderComponent(WrappedComponent);

Convention 1 : 인수로 받은 컴포넌트를 변형하지 않고 조합하기

  • 변형(mutation)된 고차 컴포넌트는 누수된 추상화(leaky abstraction)임

    • Consumer는 다른 고차 컴포넌트들과의 충돌을 피하기 위해서 고차 컴포넌트가 어떻게 구현되었는지 알아야함
  • 고차 컴포넌트(HOC)는 변경(mutation) 대신 인수로 받은 컴포넌트를 컨테이너 컴포넌트로 감싸서 조합(composition)해야함

// class component
function logProps(WrappedComponent) {
  return class extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('Current props: ', this.props);
      console.log('Previous props: ', prevProps);
    }
    render() {
      return <WrappedComponent {...this.props} />;
    }
  }
}
  • 고차 컴포넌트는 순수함수이므로 다른 고차 컴포넌트와 같이 조합하거나 자기 자신과 조합할 수 있음
  • 고차 컴포넌트는 매개변수화된 컨테이너 컴포넌트의 정의라고 볼 수 있음
    • 컨테이너 컴포넌트
      • high-level과 low-level 관심사를 분리하는 전략 중 하나
      • 컨테이너 컴포넌트는 subscriptions와 state 같은 것을 관리함
      • 컨테이너 컴포넌트는 UI 렌더링 같은 것들을 다루는 컴포넌트에 props를 전달함

Convention 2 : 래핑된 컴포넌트를 통해 관련 없는 Props 전달하기

  • 고차 컴포넌트(HOC)는 인수로 받은 컴포넌트에 기능을 추가함

    • 컴포넌트의 정의(constract)를 과감하게 변경해서는 안 됨
    • 이유 : 고차 컴포넌트에서 반환된 컴포넌트는 래핑된 컴포넌트와 비슷한 인터페이스를 가지고 있어야 함
  • 고차 컴포넌트는 특정 관심사와 관련 없는 props를 전달해야함

    • 대부분의 고차 컴포넌트에는 다음과 같은 렌더링 메서드가 포함되어 있음
render() {
  // 이 HOC에만 해당되는 extraProps는 걸러내서 컴포넌트에 전달하지 않음
  const { extraProp, ...passThroughProps } = this.props;
  
  // state나 instance method를 컴포넌트에 props로 전달함
  const injectedProp = someStateOrInstanceMethod;
  
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}
  • 이 컨벤션은 고차 컴포넌트의 유연성과 재사용성을 보장하는데 도움이 됨

Convention 3 : 조합가능성(Composability)를 끌어올리기

  • 고차 컴포넌트는 여러가지 방법으로 작성할 수 있음
    • 고차 컴포넌트는 단일 인수로 래핑된 컴포넌트만 받을 수 있음
    • 고차 컴포넌트는 일반적으로 래핑된 컴포넌트 외에 추가적인 인수를 허용함
// React Redux의 'connect'
const ConnectedComment = connect(commentSelector, commentActions);(CommentList);

// connect는 고차 컴포넌트를 반환하는 고차 함수
const enhance = connect(commentListSelector, commentListActions);
// ehnhance는 Redux store에 연결된 컴포넌트를 반환하는 고차 컴포넌트
const ConnectedComment = enhance(CommentList);
  • 단일 인수 고차 컴포넌트는 출력 타입과 입력 타입이 동일하므로 쉽게 조합할 수 있음
// before
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))

// after
const enhance = compose(
  // withRouter와 connect 둘 다 단일 인수 고차 컴포넌트임
  withRouter,
  connect(commentSelector)
)
const EnhancedComponent = enhance(WrappedComponent)

Convention 4 : 간단한 디버깅을 위한 displayName 작성 방법

  • 고차 컴포넌트에 의해 만들어진 컨테이너 컴포넌트도 React Developer Tools에 보여짐
    • 디버깅을 쉽게 하기 위해서, displayName을 고차 컴포넌트의 결과값을 나타내도록 작성함
      • '컨테이너 컴포넌트 이름(래핑된 컴포넌트 이름)'의 형식으로 작성함
function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component {/* ... */}
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
  return WithSubscription;
}

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}



Higher-Order Component 사용시 주의사항

주의사항 1 : render 메서드 안에서 Hider-Order Component 사용하지 않기

  • React의 비교 알고리즘 재조정(reconcilition)은 컴포넌트의 개별성(identity)을 가지고 기존의 서브트리에 업데이트해야 하는지 아니면 버리고 새로운 노드를 마운트해야 하는지를 결정함
    • render에서 반환된 컴포넌트가 이전 렌더링된 컴포넌트와 동일하다면(===), 새로운 서브트리와 비교하여 서브트리를 재귀적으로 업데이트함
    • render에서 반환된 컴포넌트가 이전 렌더링된 컴포넌트와 동일하지 않다면(!==), 기존 서브트리는 완전히 마운트 해제됨
render() {
  // render가 호출될 때마다 새로운 버전의 EnhancedComponent가 생성됨
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // 때문에 매번 서브트리가 마운트 해제 후 다시 마운트됨
  return <EnhancedComponent />;
}
  • 고차 컴포넌트는 새로운 컴포넌트를 반환하므로, 매번 서브트리가 마운트 해제 후 다시 마운트됨
    • 컴포넌트가 다시 마운트되면서 컴포넌트의 state와 컴포넌트의 자식 컴포넌트들이 사라짐

해결 방법

  • 컴포넌트의 정의 바깥에 고차 컴포넌트를 적용하여 컴포넌트를 한 번만 생성하기
    • 이 경우 해당 컴포넌트가 여러번 렌더링되더라도 일관성이 유지됨

주의사항 2 : 정적 메서드는 반드시 따로 복사해서 사용하기

  • 컴포넌트를 고차 컴포넌트의 인수로 넣으면, 고차 컴포넌트는 기존 컴포넌트의 정적 메서드를 가지고 있지 않음
// 정적 함수를 정의
WrappedComponent.staticMethod = function() {/*...*/}
// 컨포넌트에 고차 컴포넌트를 적용함
const EnhancedComponent = enhance(WrappedComponent);

// 컨테이너 컴포넌트에는 래핑된 컴포넌트의 정적 메서드가 없음
typeof EnhancedComponent.staticMethod === 'undefined' // true

해결 방법

  1. 메서드를 반환하기 전에 컴페이너 컴포넌트에 메서드를 복사함

    • 그러나 복사해야할 메서드를 정확히 알아야 복사할 수 있음
function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}
  1. hoist-non-react-statics 라이브러리의 hoistNonReactStatic을 사용하여 모든 non-React 정적 메서드를 자동으로 복사할 수 있음
import hoistNonReactStatic from 'hoist-non-react-statics';
function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  hoistNonReactStatic(Enhance, WrappedComponent);
  return Enhance;
}
  1. 정적 메서드를 컴포넌트와 별도로 내보내고, 고차 컴포넌트를 사용하는 파일에서 컴포넌트와 정적메서드를 import해서 사용함
// MyComponent.js
export default MyComponent;
export { someFunction };

// withMyComponent.js
import MyComponent, { someFunction } from './MyComponent.js';

주의사항 3 : 고차 컴포넌트의 래핑된 컴포넌트에 ref가 전달되지 않는다

  • 고차 컴포넌트는 ref를 래핑된 컴포넌트에 전달하지 않음
    • React에서 refkey처럼 특별하게 취급되지 때문임
    • 고차 컴포넌트의 래핑된 컴포넌트에 ref를 추가하는 경우, ref는 래핑된 컴포넌트가 아니라 가장 바깥쪽 컨테이너 컴포넌트의 인스턴스를 나타냄

해결 방법

  • React.forwardRef를 사용해서 고차 컴포넌트의 컨테이너에서 전달받은 ref를 래핑된 컴포넌트에 전달할 수 있음



Higher-Order Component as Functional Component

  • 고차 컴포넌트는 React Component를 인수로 받아서 향상된 버젼의 컴포넌트를 반환함
// Compnent => EnhancedComponent
const withHigherOrderComponent = (Component) => (props) =>
  <Component {...props} />;

usage of Higher-Order Component as funtional component

  • 데이터를 fetch할 때, fetch 결과에 따라 다른 컴포넌트를 렌더링하는 로직을 고차 컴포넌트의 컨테이너 컴포넌트에 분리해줄 수 있음
// before
const fetchData = () => {
  return { data: null, isLoading: true };
};

const App = () => {
  const { data, isLoading } = fetchData();

  if (isLoading) return <div>Loading data.</div>;
  if (!data) return <div>No data loaded yet.</div>;
  if (!data.length) return <div>Data is empty.</div>;

  return <TodoList data={data} />;
};

// after
const withConditionalFeedback = (Component) => (props) => {
  if (props.isLoading) return <div>Loading data.</div>;
  if (!props.data) return <div>No data loaded yet.</div>;
  if (!props.data.length) return <div>Data is empty.</div>;

  return <Component {...props} />;
};

const App = () => {
  const { data, isLoading } = fetchData();

  return <TodoList data={data} isLoading={isLoading} />;
};

const BaseTodoList = ({ data }) => {
  return (
    <ul>
      {data.map((item) => (
        <TodoItem key={item.id} item={item} />
      ))}
    </ul>
  );
};

const TodoList = withConditionalFeedback(BaseTodoList);
  • 조건부 실행 로직이 App 컴포넌트에서 고차 컴포넌트로 모두 옮겨짐
    • 이 경우 App 컴포넌트나 래핑된 컴포넌트에서 조건부 실행 관련 로직을 신경쓰지 않아도 됨

Configuration of Higher-Order Component

  • 고차 컴포넌트는 래핑된 컴포넌트 이외에 컴포넌트 바깥의 정보를 인수로 전달받을 수 있음
const withHigherOrderComponent = (Component, configuration) =>
  (props) => <Component {...props} />;
  • 고차 컴포넌트를 함수로 감쌈으로서, 고차 컴포넌트에서 configuration을 분리시킬 수 있음
const withHigherOrderComponent = (configuration) => (Component) =>
  (props) => <Component {...props} />;
  • 고차 컴포넌트 바깥에서 configuration을 받아서 조건에 따라 다른 UI를 렌더링할 수 있음
const withConditionalFeedback = (dataEmptyFeedback) => (Component)
  => (props) => {
    if (props.isLoading) return <div>Loading data.</div>;
    if (!props.data) return <div>No data loaded yet.</div>;

    if (!props.data.length)
      return <div>{dataEmptyFeedback || 'Data is empty.'}</div>;

    return <Component {...props} />;
  };

...

const TodoList = withConditionalFeedback('Todos are empty.')(
  BaseTodoList
);
  • 위 코드에서 dataEmptyFeedback라는 피드백이 제공되지 않더라도 일반적인 fallback을 사용할 수 있음

  • 다른 조건부 렌더링에 대해서도 피드백을 제공하는 예시

const withConditionalFeedback =
  ({ loadingFeedback, noDataFeedback, dataEmptyFeedback }) =>
  (Component) =>
  (props) => {
    if (props.isLoading)
      return <div>{loadingFeedback || 'Loading data.'}</div>;

    if (!props.data)
      return <div>{noDataFeedback || 'No data loaded yet.'}</div>;

    if (!props.data.length)
      return <div>{dataEmptyFeedback || 'Data is empty.'}</div>;

    return <Component {...props} />;
  };

...

const TodoList = withConditionalFeedback({
  loadingFeedback: 'Loading Todos.',
  noDataFeedback: 'No Todos loaded yet.',
  dataEmptyFeedback: 'Todos are empty.',
})(BaseTodoList);
  • 위 코드에서 여러 인수를 전달하는 대신 하나의 configuration obect를 인수로 전달함

    • 이렇게 할 경우, 첫 번째 인수가 아니라 두 번째 인수만 선택하고 싶을 때, null을 인수로 전달하지 않아도 됨
  • 바깥에서 고차 컴포넌트에 특정 형태(configuration)를 넣고 싶을 때, 고차 컴포넌트를 또 다른 함수로 감싸고 configuration 객체를 인수로 전달함

    • 이 경우, 고차 컴포넌트를 2번 호출해야 함
    • 고차 컴포넌트를 첫 번째로 호출했을 때는 고차 컴포넌트에 특정 형태가 들어가고, 고차 컴포넌트를 두 번째로 호출했을 때는 래핑된 컴포넌트에 고차 컴포넌트의 컨테이너에서 주어진 로직들이 들어감

Composition of Higher-Order Component

  • 고차 컴포넌트는 함수이기 때문에 여러 개의 함수로 나눠질 수 있음
const withLoadingFeedback = (Component) => (props) => {
  if (props.isLoading) return <div>Loading data.</div>;
  return <Component {...props} />;
};

const withNoDataFeedback = (Component) => (props) => {
  if (!props.data) return <div>No data loaded yet.</div>;
  return <Component {...props} />;
};

const withDataEmptyFeedback = (Component) => (props) => {
  if (!props.data.length) return <div>Data is empty.</div>;
  return <Component {...props} />;
};

const TodoList = withLoadingFeedback(
  withNoDataFeedback(
    withDataEmptyFeedback(BaseTodoList)
  )
);
  • 한 컴포넌트에 여러 고차 컴포넌트를 적용할 때 2가지 주의 사항이 있음

    1. 고차 컴포넌트들 중 우선순위가 가장 높은 고차 컴포넌트가 가장 바깥쪽 고차 컴포넌트가 됨
      • Data가 없다는 피드백을 나타내기 전에 Loading indicator를 렌더링하길 원하므로, withLoadingFeadback 고차 컴포넌트가 withDataFeedback보다 우선순위가 높음
        • withLoadingFeadback 고차 컴포넌트가 가장 바깥쪽 컴포넌트가 됨
    2. 고차 컴포넌트들은 서로 의존할 수 있으므로 주의해야 함
      • withDataEmptyFeedback 고차 컴포넌트는 withNoDataFeedback 고차 컴포넌트의 !data null 체크의 반환값에 의존함
      • withNoDataFeedback 고차 컴포넌트의 반환값이 없다면, !props.data.length에 대한 null pointer 예외가 발생할 수 있음
  • compose() 함수를 사용하여 고차 컴포넌트 함수들을 조합해서 사용할 수 있음

    • compose() 함수는 함수 배열을 인수로 받아서, 오른쪽에서 왼쪽순으로 함수의 반환값을 다음 함수의 인수로 전달함
const compose = (...fns) =>
  fns.reduceRight((prevFn, nextFn) =>
    (...args) => nextFn(prevFn(...args)),
    value => value
  );

const TodoList = compose(
  withLoadingFeedback,
  withNoDataFeedback,
  withDataEmptyFeedback
)(BaseTodoList);
  • 고차 컴포넌트에 객체 대신 문자열을 configuration으로 받을 수 있음
const withLoadingFeedback = (feedback) => (Component) => (props) => {
  if (props.isLoading) return <div>{feedback}</div>;
  return <Component {...props} />;
};

const withNoDataFeedback = (feedback) => (Component) => (props) => {
  if (!props.data) return <div>{feedback}</div>;
  return <Component {...props} />;
};

const withDataEmptyFeedback = (feedback) => (Component) => (props) => {
  if (!props.data.length) return <div>{feedback}</div>;
  return <Component {...props} />;
};

const TodoList = compose(
  withLoadingFeedback('Loading Todos.'),
  withNoDataFeedback('No Todos loaded yet.'),
  withDataEmptyFeedback('Todos are empty.')
)(BaseTodoList);

0개의 댓글