횡단 관심사(Cross-cutting Concerns)에 고차 컴포넌트 사용하기

규모가 큰 애플리케이션에서 동일한 로직(데이터를 받아와서, 렌더링한다)이 반복되고 이를 많은 컴포넌트에서 사용한다면 추상화의 필요성이 생긴다. 이러한 경우에 고차 컴포넌트를 사용하자.

class CommentList extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      // "DataSource" 는 글로벌 데이터 소스입니다.
      comments: DataSource.getComments()
    };
  }

  componentDidMount() {
    // 변화감지를 위해 리스너를 추가합니다.
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // 리스너를 제거합니다.
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    // 데이터 소스가 변경될때 마다 comments를 업데이트합니다.
    this.setState({
      comments: DataSource.getComments()
    });
  }

  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
      </div>
    );
  }
}
class BlogPost extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      blogPost: DataSource.getBlogPost(props.id)
    };
  }

  componentDidMount() {
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    this.setState({
      blogPost: DataSource.getBlogPost(this.props.id)
    });
  }

  render() {
    return <TextBlock text={this.state.blogPost} />;
  }
}

DataSource를 구독하는 CommentListBlogPost 같은 컴포넌트를 생성하는 함수를 작성한다. 구독한 데이터를 prop으로 전달받는 자식 컴포넌트를 파라미터 중 하나로 받는 함수를 만든다. 이 함수를 withSubscription라고 하자.

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);

첫번째 파라미터는 래핑된 컴포넌트다. 두 번째 파라미터는 DataSource와 현재 props를 가지고 컴포넌트에서 관심 있는 데이터를 검색한다.

CommentListWithSubscriptionBlogPostWithSubscription가 렌더링될 때 CommentListBlogPostDataSource에서 가장 최근에 검색된 데이터를 data prop으로 전달한다.

// 이 함수는 컴포넌트를 매개변수로 받고..
function withSubscription(WrappedComponent, selectData) {
  // ...다른 컴포넌트를 반환하는데...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ... 구독을 담당하고...
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }

    render() {
      // ... 래핑된 컴포넌트를 새로운 데이터로 랜더링 합니다!
      // 컴포넌트에 추가로 props를 내려주는 것에 주목하세요.
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

고차 컴포넌트는 원본 컴포넌트를 컨테이너 컴포넌트로 Wrapping하여 조합한다. 이는 사이드 이펙트가 없는 순수 함수이다.

고차 컴포넌트는 데이터의 사용 이유와 방법, 어디서부터 왔는지 알 필요가 없다.

결국 같은 일을 하는 컴포넌트를 고차 컴포넌트로 추상화 하고 prop을 통해서만 계약을 맺었다. 이는 래핑된 컴포넌트에 동일한 prop만 전달한다면 다른 고차 컴포넌트를 만들 수 있다. 이는 객체지향에서 OCP를 지키면서 유연성을 확보한 전략과 동일해 보인다.


원본 컴포넌트를 변경하지 마세요. 조합(Composition)하세요.

원본 컴포넌트를 변경하여 사용하기 보다, 새로운 컴포넌트를 반환하는 방식을 이용해라.

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('Current props: ', this.props);
      console.log('Previous props: ', prevProps);
    }
    render() {
      // 들어온 component를 변경하지 않는 container입니다. 좋아요!
      return <WrappedComponent {...this.props} />;
    }
  }
}