React의 새로운 패러다임, React Hooks

김카레·2018년 12월 15일
29
post-thumbnail
post-custom-banner

React Hooks는 지난 ReactConf 2018에서 발표된, class없이 state를 사용할 수 있는 새로운 기능입니다.
현재 v16.7.0-alpha 버전이라 아직 서비스에 적용할 수는 없지만, 요즘 커뮤니티에서 완전 핫해서; 미리 살펴보려고 합니다.

Hooks가 왜 필요한가?

문제의 시작

리액트를 처음 접했을 때는 UI을 재사용 가능한 component들로 나누고 다른데서도 쓸 수 있을 줄 알았는데, 사실 그게 쉽지 않았습니다.
로직이 UI 및 리액트 life cycle에 너무 밀접하게 결합되어 있었습니다.
중복된 코드들이 늘어나자 내가 왜 이걸 쓰고 있나 싶었죠.

/* 로직과 UI가 섞인 Component */
class HomeBanners extends Component {
	componentWillMount() { /* 배너 정보 가져오기 */ }

	render() {
		return <>
			{this.state.banners.map((banner, index) => /* 배너 UI */}
		</>
	}
 }

/* 비슷한 또 다른 배너 Component */
class OtherBanners extends Component { ... }

HOC(Higher Order Component)

그래서 시도한 방법이 HOC(Higher Order Component) 였습니다.
화면에서 재사용 가능한 로직만을 분리해서 component로 만들고, 재사용 불가능한 UI와 같은 다른 부분들은 parameter로 받아서 처리하는 방법이었습니다.

export default class Banners extends Component {
	componentWillMount() { /* 배너 정보 가져오기 */ }

	render() {
		return (this.props.children)(this.state.banners)
	}
 }

/* 재사용 */
<Banners>{banners => /* 홈 Banner용 UI */ }</Banners>
<Banners>{banners => /* 다른 Banner용 UI */ }</Banners>

HOC로 해결되지 않는 문제들

얼핏 문제가 해결된 것 같아 보이지만...
이렇게 로직을 분리해서 둘러싸고, 둘러싸고, 또 둘러쌌더니... wrapper 지옥의 문이 열렸습니다.
Wrapper hells
depth가 좀 깊어지면 뭐 어때 싶기도 하지만, 사실 문제는 이 뿐만이 아닙니다.
API도 호출하고, 이벤트도 등록하고, 뭔가 subscribe도 해야 하는 복잡한 component가 있다고 해봅시다.
여러 로직이 componentWillUnmount, componentDidMount 등의 리액트 life cycle에 흩어지게 됩니다. 함수는 단일 책임 원칙을 벗어나게 되고, 코드는 복잡해지고, 테스트는 점차 어려워지게 됩니다.

export default class ComplexComponent extends Component {
  componentDidMount(){
    /* 얘네들이 왜 여기에 같이 있어야 하나 */
    this.callSomething();
    this.subscribe();
    document.addEventListener(...);
  }

  componentWillUnmount(){
    this.cancelSomething();
    this.unsubscribe();
    document.removeEventListener(...);
  }
  
  render(){ /* 복잡한 UI */ }
}

로직을 분리하면서도 wrapper hells을 피할 수 있고, 리액트 component life cycle에도 종속적이지 않게 코딩하는 방법이... 과연 있을까요?

React Hooks

React Hooks의 등장

이런 문제를 해결하기 위해서 Facebook에서는 2018년 10월 ReactConf 2018에서 Hooks이라는 새로운 방법을 제안하였고, 현재는 RFC를 통해 활발히 논의중인 상태입니다.
스크린샷 2018-12-16 오후 7.37.08.png

Hook이란?

그럼 그 해결책이 되는 hooks이란 무엇일까요?
이름만 봐서는 어떤 process중에 hook한다는 의미인듯 한데, 무엇을 hook 하겠다는 것일까요?
첫번째 줄에서 말한 Hooks의 정의에 대해 잠깐 다시 리마인드 해보겠습니다.

class없이 state를 사용할 수 있는 새로운 기능

위의 정의처럼 class 없이 state를 사용할 수 있으려면, 이제 component들은 아래와 같이 선언되어야 합니다.

function Example(props) {
  return <div />;
}

setState는 어디 있나요? componentDidMount는요?
네, 이제 없습니다. 대신 state와 React life cycle을 hook 할 수 있는 기능을 제공합니다.

State Hook

첫번째는 state를 hook할 수 있는 기능입니다.
기존에는 아래와 같이 class의 state와 setState로 상태를 관리했다면,

class Example extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      name: "이름"
    };
  }
  
  render() {
  	return <input value={name} onClick={(e) => this.setState({ name : e.target.value })} />;
  }
}

이제는 단순히 기존 class의 render() 단계에 해당되는 로직에 집중하면 됩니다.
그리고 state가 필요한 경우 useState를 사용하여 hook 합니다.

import { useState } from 'react';

function Example() {
  const [name, setName] = useState("이름");

  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

useState는 인자로 초기값을 받고, 현재 상태(name)와 현재 상태를 업데이트할 수 있는 함수(setName)를 반환해줍니다.
그럼 조금 더 재사용 가능한 형태로 수정해보겠습니다.

export default function Example() {
  const name = useFormInput("name");
  const email = useFormInput("email");

  return <>
    <input {...name} />
    <input {...email} />
  </>
}

/* 재사용 가능한 로직만 분리해봅시다 */
export default function useFormInput(defaultValue: string) {
  const [value, setValue] = useState(defaultValue);
  function changeValue(e) {
    setValue(e.target.value);
  }
  return {
    value,
    onChange : changeValue
  }
}

훨씬 심플하고 직관적인 코드가 되었습니다.

Effect Hook

두 번째, 기존 React life cyle에 해당하는 로직들은 useEffect를 사용하여 hook할 수 있습니다.
이름이 effect인 이유는 뭘까요? 그건 저희가 주로 수행하는 data를 fetch하는 등의 작업들이 side effect에 해당되기 때문입니다.
그럼 어떻게 개선될 수 있는지 확인해보겠습니다. 기존에 아래와 같이 구현했다면,

class Data extends React.Component {
  constructor(props) {
    super(props);
    this.state = { item : null };
    this.setData = this.setData.bind(this);
  }

  componentDidMount() {
    API.getData()
      .then((response) => { this.setData(response) });
  }

  setData(data) {
    this.setState({ item: data });
  }
  
  render() {
    const isLoading = (this.state.item == null);
  	return { isLoading ? "Loading..."  : this.state.item }
  }
}

이제는 useEffect를 사용하여 다음과 같이 구현할 수 있습니다.

import { useState, useEffect } from 'react';

export function Data() {
  const [data, setData] = useState(null);

  useEffect(() => {
    API.getData()
      .then((response) => { setData(response) });
  }, []);
  
  const isLoading = (data == null);
  return { isLoading ? "Loading..."  : data }
}

그럼 useEffect는 React life cycle 중 componentDidMount에만 해당될까요?
아닙니다. 정확히는 componentDidMount와 componentDidUpdate, componentWillUnmount를 합쳐놓은 것에 해당됩니다.

function useEffect(effect: EffectCallback, inputs?: InputIdentityList)

기본적으로 useEffect에 넘겨준 effect는 render할 때 마다 매번 호출되게 되는데(componentDidMount : 초기, componentDidUpdate : 업데이트),
두번째 파라미터인 inputs으로 특정 state가 변경된 경우만 effect가 실행되게 지정할 수 있습니다.

useEffect(() => func(), [count]); // count state가 변경될 때만 func 실행

예제에서는 두번째 인자로 빈 배열 []을 넘겨주어 마치 componentDidMount처럼 동작하는 것처럼 보입니다.
앞서 useEffect가 componentDidMount와 componentDidUpdate, componentWillUnmount를 합쳐 놓은 것이라고 했는데, 그럼 마지막 componentWillUnmount는 어떻게 처리할 수 있을까요?
useEffect에서는 인자로 넘겨주는 effect 함수의 return값이 있는 경우, hook의 cleanup 함수로 인식하고 다음 effect가 실행되기 전에 실행해줍니다.

useEffect(() => {
  window.addEventListener("mousemove", logMousePosition);
  return () => {
    window.removeEventListener("mousemove", logMousePosition);
  };
}, []);

정확히는 componentWillUnmount처럼 unmount 되는 시점에 한번만 호출되는 것이 아니라, effect와 쌍을 이뤄 호출됩니다.

window.addEventListener("mousemove", logMousePosition); // mount 
/* inputs 지정된 특정 값이 업데이트 된 경우 */
window.removeEventListener("mousemove", logMousePosition); // cleanup
window.addEventListener("mousemove", logMousePosition); 
window.removeEventListener("mousemove", logMousePosition); // unmount

마지막으로 useEffect를 사용하여, data를 서버로 부터 fetch하는 재사용 가능한 로직으로 분리해보겠습니다.

export function Example() {
  const profile = useFetch(API.fetchProfile);
  const friends = useFetch(API.fetchFriends);
  
  return <>
    { profile.isLoading ? "Loading..."  : profile.data }
    { friends.isLoading ? "Loading..."  : friends.data }
    </>
}

export function useFetch(func, conditions = []) {
  const [data, setData] = useState(null);

  const fetch = () => {
    func()
      .then(response => {
        setData(response);
      })
  };

  useEffect(fetch, conditions);

  const isLoading = (data == null);
  return { data, isLoading };
}

Custom Hooks

이제는 useState나 useEffect를 사용해서 직접 custom한 hook을 만들어 개선해 나가면 될까요?
아마 예상하신 것보다는 작업량이 더 작을 수 있을 것 같습니다.
이미 hook을 이용해서 promise를 처리하는 usePromise

const { isLoading, data } = usePromise(fetchData, { resolve: true });

마우스 position을 처리하는 useWindowMousePosition와 같은 오픈소스들이 존재합니다.

const { x, y } = useWindowMousePosition();

대부분의 것들이 이미 구현되어 있습니다. 그 밖에 이런 custom한 hook들은 아래 링크에서 확인해볼 수 있습니다.
https://www.hooks.guide/

Reference

profile
김카레
post-custom-banner

5개의 댓글

comment-user-thumbnail
2018년 12월 18일

좋은 글 감사합니다.

답글 달기
comment-user-thumbnail
2018년 12월 18일

const isLoading = (item == null);
return { isLoading ? "Loading..." : item }

item이 아니라 data가 아닌지요?

잘몰라서 물어보는겁니다 :)

1개의 답글
comment-user-thumbnail
2019년 11월 5일

좋아요

답글 달기
comment-user-thumbnail
2020년 7월 11일

잘 읽었습니다.

답글 달기