React Hooks

ZeroJun·2022년 7월 27일
1

React

목록 보기
12/13


Hook은 함수 컴포넌트에서 사용하는 메소드다. 함수형 컴포넌트 이전에는 class컴포넌트가 있었다.

아래는 카운트팅을 하는 단순한 class컴포넌트다.

class Counter extends Component {
    constructor(props) {
        super(props);
        this.state = {
            counter: 0
        }
        this.handleIncrease = this.handleIncrease.bind(this);
    }

    handleIncrease = () => {
        this.setState({
            counter: this.state.counter + 1
        })
    }

    render(){
       return (
            <div>
                <p>You clicked {this.state.counter} times</p>
                <button onClick={this.handleIncrease}>
                    Click me
                </button>
            </div>
       ) 
    }
}

이런 클래스 컴포넌트는 복잡해질수록 이해하기 어려워졌고, 컴포넌트 사이에서 상태 로직을 재사용하기 어렵다는 단점이 있었다. 또한 React의 클래스 컴포넌트를 사용하기 위해서는 JavaScript의 this키워드가 어떤 방식으로 동작하는지 알아야 하는데, 이는 문법을 정확히 알지 못하면 동작 방식 자체를 정확히 이해하기 어렵게 만들곤 했다.

그래서 React는 점진적으로 클래스 컴포넌트에서 함수 컴포넌트로 넘어갔다. 다만 이전까지의 함수 컴포넌트는 클래스 컴포넌트와는 다르게 상태 값을 사용하거나 최적화할 수 있는 기능들이 조금 미진했는데, 그 부분들을 보완하기 위해 Hook이라는 개념을 도입했다.

아래는 위의 Class컴포넌트를 함수형 컴포넌트로 작성한 것이다.

function Counter () {
    const [counter, setCounter] = useState(0);

    const handleIncrease = () => {
        setCounter(counter + 1)
    }

    return (
        <div>
            <p>You clicked {counter} times</p>
            <button onClick={handleIncrease}>
                Click me
            </button>
        </div>
    )
}

확실히 클래스형 컴포넌트보다 보기 쉽다. 여기서 useState가 바로 Hook이다.
Counter 컴포넌트에서 useState() Hook을 호출해 함수 컴포넌트 안에 state를 추가한 형태다. 이 state는 리랜더링 되어도 그대로 유지될 것이다.

Hook이란?

React 공식문서에서 hook을 이렇게 말한다.

Hook은 React 16.8에 새로 추가된 기능입니다. Hook은 class를 작성하지 않고도 state와 다른 React의 기능들을 사용할 수 있게 해줍니다.

다시말해 class컴포넌트에서 hook은 동작하지 않는다.

Hook 사용 규칙

1. 리액트 함수의 최상위에서만 호출해야 한다.

반복문, 조건문, 중첩된 함수 내에서 Hook을 실행하면 예상한대로 동작하지 않을 우려가 있다.
컴포넌트 안에는 useState나 useEffect같은 Hook들이 여러 번 사용될 수 있는데, React는 이 Hook을 호출되는 순서대로 저장해놓는다.

그런데 조건문, 반복문 안에서 Hook을 호출하게 되면 호출되는 순서로 저장하기 어려워지고, 결국 예기치 못한 버그를 초래할 수 있다.

2. 오직 리액트 함수 내에서만 사용되어야 한다.

이는 리액트 함수형 컴포넌트나 커스텀 Hook이 아닌 다른 일반 JavaScript 함수 안에서 호출해서는 안된다는 의미다.

window.onload = function () {
    useEffect(() => {
        // do something...
    }, [counter]);
}

애초에 hook은 React의 함수 컴포넌트 내에서 사용되도록 만들어진 메소드이기 때문에 근본적으로 일반 javascript함수 내에서는 정상적으로 돌아가지 않는다. 따라서 이 규칙 또한 반드시 준수해야 하는 규칙이다.

useMemo

컴포넌트는 기본적으로 상태가 변경되거나 부모 컴포넌트가 렌더링 될 때마다 리렌더링 하는 구조로 이루어져 있다. 그러나 너무 잦은 리렌더링은 앱에 좋지 않은 성능을 끼친다.

React Hook은 함수 컴포넌트가 상태를 조작하고 최적화 기능을 사용할 수 있게금 하는 메소드다. 그 중 렌더링 최적화를 위한 Hook이 useCallback과 useMemo다.

useMemo는 특정 값을 재사용하고자 할 때 사용하는 Hook이다.

function Calculator({value}) {
 const result = calculate(value);
  
  return <>
    <div>
      {result}
      
    <div>
  </>;
}

이 컴포넌트는 props로 넘어온 value값을 calculate함수에 넘겨서 result값을 구한 후 div엘리먼트로 출력을 하고 있다. 만약 여기서 calculate가 내부적으로 복잡한 연산을 해야 하는 함수라 계산된 값을 반환하는데에 시간이 몇 초 이상 걸린다고 가정해보자. 그렇다면 해당 컴포넌트는 렌더링 할 때마다 이 함수를 계속 호출할 것이고, 그 때마다 시간이 몇 초 이상 소요가 될 것이다. 이 몇 초의 지연은 렌더링에도 영향을 미칠 것이고, 사용자는 "앱의 로딩 속도가 느리네?"라는 생각을 할 것이다.

import { useMemo } from "react";

function Calculator({value}) {
 const result = useMemo(() => calculate(value), [value]);
  
 return <>
   <div>
     {result}
   </div>
 </>
}

만약 이 코드에서 렌더링을 할 때마다 이 value값이 계속 바뀌는 게 아닐 경우 이 값을 어딘가에 저장을 해뒀다가 다시 꺼내서 쓸 수만 있다면 굳이 calculate함수를 호출할 필요가 없을 것이다. 여기서 useMemo Hook을 사용할 수 있다.

이런 식으로 useMemo를 호출하여 calculate를 감싸주면, 이전에 구축된 렌더링과 새로이 구축되는 렌더링을 비교해 value값이 동일한 경우에는 이전 렌더링의 value값을 그대로 재활용 할 수 있게 된다.이는 메모이제이션 개념과 긴밀한 관계가 있다.

Memoization

메모이제이션은 알고리즘에서 자주 나오는 개념이다. 기존에 수행한 연산의 결과값을 메모리에 저장을 해두고, 동일한 입력이 들어오면 재활용하는 프로그래밍 기법을 말한다. 이 메모이제이션을 적절히 사용한다면 굳이 중복 연산을 할 필요가 없기 때문에 앱의 성능을 최적화할 수 있다.

useMemo는 바로 이 개념을 이용하여 복잡한 연산의 중복을 피하고 React앱의 성능을 최적화시킨다. 직접 메모이제이션 개념을 이용하여 로직을 구현할 수도 있으나, useMemo Hook을 호출하면 이런 로직을 직접 구현하는 것을 대신해주기 때문에 훨씬 간편하다고 할 수 있다.

useCallback

useCallback 또한 useMemo와 마찬가지로 메모이제이션 기법을 이용한 Hook이다. useMemo는 값의 재사용을 위해 사용하는 Hook이라면, useCallback은 함수의 재사용을 위해 사용하는 Hook이다.

function Calculator({x,y}) {
	const add = () => x + y;
  
  	return <>
      <div>
        {add()}
      </div>
    </>
}

현재 이 Calculator 컴포넌트 내에는 add라는 함수가 선언이 되어 있는 상태다. 이 add함수는 props로 넘어온 x와 y값을 더해 div 태그에 값을 출력하고 있다. 이 함수는 해당 컴포넌트가 렌더링 될 때마다 새롭게 만들어질 것이다.

useMemo와 마찬가지로, 해당 컴포넌트가 리렌더링 되더라도 그 함수가 의존하고 있는 값인 x와 y가 바뀌지 않는다고 생각해보자. 그렇다면 함수 또한 메모리 어딘가에 저장해 뒀다가 다시 꺼내 쓸 수 있을 것이다.

이 때 useCallback Hook을 사용하면 그 함수가 의존하는 값들이 바뀌지 않는 한 기존 함수를 계속해서 반환한다. 즉 x와 y값이 동일하다면 다음 렌더링 때 이 함수를 다시 사용한다.

import React, { useCallback } from "react";

function Calculator({x, y}) {
 	const add = useCallback(() => x + y, [x, y]);
  
  	return <>
      <div>
        {add()}
      </div>
    </>
}

사실 useCallback만 사용해서는 useMemo에 비해 괄목할 만한 최적화를 느낄 수는 없다. 왜냐하면 useCallback은 함수를 호출하지 않는 Hook이 아니라 그저 메모리 어딘가에 함수를 꺼내서 호출하는 Hook이기 때문이다. 따라서 단순히 컴포넌트 내에서 함수를 반복해서 생성하지 않기 위해서 useCallback을 사용하는 것은 큰 의미가 없거나 오히려 손해인 경우도 있다. 언제 사용하는게 좋을까? 자식 컴포넌트의 props로 함수를 전달해줄 때 이 useCallback을 사용하기가 좋다.

useCallback과 참조 동등성

useCallback은 참조 동등성에 의존한다. React는 JavaScript언어로 만들어진 오픈소스 라이브러리이기 때문에 기본적으로 JavaScript문법을 따라간다. JavaScript에서 함수는 객체다 객체는 메모리에 저장할 때 값을 저장하는 게 아니라 값의 주소를 저장하기 때문에 반환하는 값이 같을 지라도 ===로 비교하면 false가 출력된다.

이런 원리에 따라 React는 리렌더링 시 함수를 새로 만들어서 호출한다. 새로 만들어 호출된 함수는 기존의 함수와 같은 함수가 아니다. 그러나 useCallback을 이용해 함수 자체를 저장해서 다시 사용하면 함수의 메모리 주소 값을 저장했다가 다시 사용한다는 것과 같다고 볼 수 있다. 따라서 React컴포넌트 함수 내에서 다른 함수의 인자로 넘기거나 자식 컴포넌트의 props으로 넘길 때 예상치 못한 성능 문제를 막을 수 있다.

Custom Hooks

개발자가 스스로 커스텀한 훅을 의미하며 이를 이용해 반복되는 로직을 함수로 뽑아내어 재사용할 수 있다.

여러 url을 fetch할 때, 여러 input에 의한 상태 변경 등 반복되는 로직을 동일한 함수에서 작동하게 하고 싶을 때 커스텀 훅을 주로 사용한다.

이를 이용하면

  1. 상태관리 로직의 재활용이 가능
  2. 클래스 컴포넌트보다 적은 양의 코드로 동일한 로직 구현 가능
  3. 함수형으로 작성하기 때문에 보다 명료하다. (e.g. useSomething)

아래는 React 공식 문서에 있는 친구가 online인지 확인하는 컴포넌트와 online이면 초록색을 표시하는 컴포넌트다.

//FriendStatus : 친구가 online인지 offline인지 return하는 컴포넌트
function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

//FriendListItem : 친구가 online일 때 초록색으로 표시하는 컴포넌트
function FriendListItem(props) {
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

이 두 컴포넌트는 정확하게 똑같이 쓰이는 로직이 존재한다. 이 로직을 빼내서 두 컴포넌트에 공유하고 싶을 때 Custom Hook을 사용하면 된다.

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}

두 컴포넌트에서 사용하기 위해 동일하게 사용되고 있는 로직을 분리하여 함수 useFreiendStatus로 만든다. 이렇게 Custom Hook을 정의할 때는 일종의 규칙이 필요하다.

  • Custom Hook을 정의할 때는 함수 이름 앞에 use를 붙이는 것이 규칙이다.
  • 대개의 경우 프로젝트 내의 hooks 디렉토리에 Custom Hook을 위치시킨다.
  • Custom Hook으로 만들 때 함수는 조건부 함수가 아니어야 한다. 즉, return 하는 값은 조건부여서는 안된다. 그렇기 때문에 위의 useFrendStatus Hook은 온라인 상태의 여부를 boolean타입으로 반환하고 있다.

이렇게 만들어진 Custom Hook은 Hook내부에 useState와 같은 React 내장 Hook을 사용하여 작성할 수 있다. 일반 함수 내부에서는 React 내장 Hook을 불러 사용할 수 없지만 Custom Hook에서는 가능하다는 것 또한 알아두면 좋다.

아래는 useFriendStatus를 두 컴포넌트에 적용한 것이다.

function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id);

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id);

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

로직을 분리해 Custom Hook으로 만들었기 때문에 두 컴포넌트는 더 직관적으로 확인이 가능하다.

그러나 같은 Custom Hook을 사용했다고 해서 두 개의 컴포넌트가 같은 state를 공유하는 것은 아니다. 그저 로직만 공유할 뿐, state는 컴포넌트 내에서 독립적으로 정의되어 있다.

Custom Hook의 예시

const useFetch = ( initialUrl:string ) => {
	const [url, setUrl] = useState(initialUrl);
	const [value, setValue] = useState('');

	const fetchData = () => axios.get(url).then(({data}) => setValue(data));	

	useEffect(() => {
		fetchData();
	},[url]);

	return [value];
};

export default useFetch;

여러 url을 fetch할 때 쓸 수 있는 useFetch Hook

import { useState, useCallback } from 'react';

function useInputs(initialForm) {
  const [form, setForm] = useState(initialForm);
  // change
  const onChange = useCallback(e => {
    const { name, value } = e.target;
    setForm(form => ({ ...form, [name]: value }));
  }, []);
  const reset = useCallback(() => setForm(initialForm), [initialForm]);
  return [form, onChange, reset];
}

export default useInputs;

여러 input에 의한 상태 변경을 할 때 쓸 수 있는 useInputs Hooks

React에서의 코드 분할

React는 SPA인데, 사용하지 않는 모든 컴포넌트까지 한 번에 불러오기 때문에 첫 화면이 렌더링 될때까지의 시간이 오래걸린다. 그래서 사용하지 않는 컴포넌트는 나중에 불러오기 위해 코드 분할 개념을 도입했다.

React에서 코드 분할하는 방법은 dynamic import(동적 불러오기)를 사용하는 것이다. 그 전까지는 코드 파일의 가장 최상위에서 Import지시자를 사용해 사용하고자 하는 라이브러리 및 파일을 불러오는 방법을 사용했었다. 이를 static import(정적 불러오기)라고 한다.

Static Import

import moduleA from "library";

form.addEventListener("submit", e => {
  e.preventDefault();
  someFunction();
});

const someFunction = () => {
}

기존에는 항상 import 구문은 문서의 상위에 위치해야 했고, 블록문 안에서는 위치할 수 없는 제약 사항이 있었다. 왜냐하면 번들링 시 코드 구조를 분석해 모듈을 한 데 모으고 사용하지 않는 모듈은 제거하는 등의 작업을 하는데, 코드 구조가 간단하고 고정이 되어 있을 때에야만이 작업이 가능해지기 때문이었다.

그러나 이제는 구문 분석 및 컴파일해야 하는 스크립트의 양을 최소화 시키기 위해 dynamic import구문을 지원한다.

Dynamic Import

form.addEventListener("submit", e => {
  e.preventDefault();
	/* 동적 불러오기는 이런 식으로 코드의 중간에 불러올 수 있게 된다. */
  import('library.moduleA')
    .then(module => module.default)
    .then(someFunction())
    .catch(handleError());
});

const someFunction = () => {
    /* moduleA를 여기서 사용합니다. */
}

이런 식으로 dynamic import를 사용하게 되면 불러온 moduleA 가 다른 곳에서 사용되지 않는 경우, 사용자가 form을 통해 양식을 제출한 경우에만 가져오도록 할 수 있다.

dynamic import는 then 함수를 사용해 필요한 코드만 가져온다. 가져온 코드에 대한 모든 호출은 해당 함수 내부에 있어야 합니다. 이 방식을 사용하면 번들링 시 분할된 코드(청크)를 지연 로딩시키거나 요청 시에 로딩할 수 있다.

이 dynamic import는 React.lazy 와 함께 사용할 수 있다.

React.lazy()

React.lazy함수를 사용하면 dynamic import를 사용해 컴포넌트를 렌더링할 수 있다. 이를 사용하면 초기 렌더링 지연시간을 어느정도 줄일 수 있게 된다.

const Compnent = React.lazy(() => import('./Component));

React.lazy로 감싼 컴포넌트는 단독으로 쓰일 수는 없고, React.suspense 컴포넌트의 하위에서 렌더링을 해야 한다.

React.Suspense

Router로 분기가 나누어진 컴포넌트들을 위 코드처럼 lazy를 통해 import하면 해당 path로 이동할 때 컴포넌트를 불러오게 되는데 이 과정에서 로딩하는 시간이 생기게 된다. Suspense는 아직 렌더링이 준비되지 않은 컴포넌트가 있을 때 로딩 화면을 보여주고, 로딩이 완료되면 렌더링이 준비된 컴포넌트를 보여주는 기능이다.

React.laze와 Suspense의 적용

앱에 코드 분할을 도입할 곳을 결정하는 것은 사실 까다롭다. 중간에 적용시키는 것보다는 웹 페이지를 불러오고 진입하는 단계인 Route에 이 두 기능을 적용시키는 것이 좋다.

import { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
);

여기서 suspense컴포넌트의 fallback prop은 컴포넌트가 로드될 때 까지 기다리는 동안 로딩 화면으로 보여줄 React엘리먼트를 받아들인다.

이렇게 하면 초기 렌더링 시간이 줄어드는 분명한 장점이 있으나 페이지를 이동하는 과정마다 로딩 화면이 보여지기 때문에 서비스에 따라서 적용 여부를 결정해야 한다.

0개의 댓글