Frontend/Today I Learned

[React] Hooks - useContext, memoization

joycie416 2024. 8. 26. 21:54

이번에는 지난주에 예고한대로 `useContext` hook과 `memoization`에 대해 살펴보려고 한다.

 

1. `useContext`

React context의 필요성

useContext를 사용하기 전에는 prop을 부모 컴포넌트에서 자식 컴포넌트로 전달해주며 데이터를 전달했다. 하지만 중간 컴포넌트에서 해당 데이터가 불필요한 경우도 많을 것이고 컴포넌트 사이의 거리가 아주 멀면 prop drilling이 발생할 것이다. Prop drilling이 발생하면 다음과 같은 문제점이 생긴다.

  • 깊이가 너무 깊어지면 어떤 컴포넌트로부터 전달된 prop인지 파악하기 어려움
  • 어떤 컴포넌트에서 오류가 발생할 경우 추적이 힘들어 대처가 늦어짐

이 때 `useContext` hook, 즉 context API를 통해 쉽게 전역 상태 관리를 할 수 있게 된다.

 

Context API

  • `createContext` : context 생성
  • `useContext` : context를 구독하고 해당 context의 현재 값을 조회할 수 있음
  • `Provider` : context를 하위 컴포넌트에 전달.

할아버지 컴포넌트에서 손자 컴포넌트에 `houseName`과 `pocketMoney`를 전달하는 경우를 생각해보자.

 

먼저 `src/context` 폴더를 생성해 원하는 context 파일을 만들자.

// src/context/FamilyContext.js

import { createContext } from "react";

export const FamilyContext = createContext(null);

 

여기서 `null`은 초기 상태로 아직 생성된 context가 없어 주어진 값이다.

 

할아버지 컴포넌트를 다음과 같이 작성하자.

// src/components/GrandFather.jsx

import React from "react";
import Father from "./Father";
import { FamilyContext } from "../context/FamilyContext";

function GrandFather() {
  const houseName = "김";
  const pocketMoney = 50000;

  return (
    <FamilyContext.Provider value={{ houseName, pocketMoney }}>
      <Father />
    </FamilyContext.Provider>
  );
}

export default GrandFather;

 

이제 GrandFather의 하위 컴포넌트에서는 모두 `houseName`과 `pocketMoney`에 접근할 수 있다. 이때 `Father` 컴포넌트에 주어진 prop이 없다는 것이 포인트이다. `Child` 컴포넌트에  `houseName`과 `pocketMoney`를 전달하려면 기존에는 `Father` 컴포넌트에서 전달받았어야했지만 context API를 사용해 prop으로 전달하지 않아도 된다.

 

// src/components/Father.jsx

import React from "react";
import Child from "./Child";

function Father() {
  return <Child />;
}

export default Father;
// src/components/Child.jsx

import React, { useContext } from "react";
import { FamilyContext } from "../context/FamilyContext";

function Child() {
  const stressedWord = {
    color: "red",
    fontWeight: "900",
  };

  const data = useContext(FamilyContext);
  console.log("data", data);

  return (
    <div>
      저는 <span style={stressedWord}>{data.houseName}</span>씨 가문 손자에요.
      <br />
      할아버지가 용돈을 <span style={stressedWord}>{data.pocketMoney}</span>원
      주셨어요.
    </div>
  );
}

export default Child;

 

 

실무에서 `FamilyContext.js` 파일과 `GrandFather.jsx` 파일을 하나로 합쳐 작성하는 경우도 많다고 하니 참고하자.

// src/context/FamilyContext.jsx

import React from "react";
import Father from "./Father";
import { createContext } from "react";

export const FamilyContext = createContext(null);

function FamilyContextProvider({children}) {
  const houseName = "김";
  const pocketMoney = 50000;

  return (
    <FamilyContext.Provider value={{ houseName, pocketMoney }}>
      {children}
    </FamilyContext.Provider>
  );
}

export default FamilyContextProvider;

 

Context API 주의사항

Provider가 전달한 value가 달라지면 해당 context가 포함하는 모든 컴포넌트가 리렌더링 된다. 따라서 value 부분을 신경써서 불필요한 리렌더링이 일어나지 않도록 주의해야 한다.

 

 

바로 위에서 설명했듯이 상위 컴포넌트가 리렌더링 되면 변화가 없는 컴포넌트도 불필요하게 리렌더링되는 문제가 있다. 이를 해결하는 세 가지 방법을 소개하고자 한다.

 

 

2. `React.memo`

하위 컴포넌트의 불필요한 리렌더링을 막아준다.

// src/App.jsx

import React, { useState } from "react";
import Box1 from "./components/Box1";
import Box2 from "./components/Box2";
import Box3 from "./components/Box3";

const boxesStyle = {
  display: "flex",
  marginTop: "10px",
};

function App() {
  console.log("App 컴포넌트가 렌더링되었습니다!");

  const [count, setCount] = useState(0);

  // 1을 증가시키는 함수
  const onPlusButtonClickHandler = () => {
    setCount(count + 1);
  };

  // 1을 감소시키는 함수
  const onMinusButtonClickHandler = () => {
    setCount(count - 1);
  };

  return (
    <>
      <h3>카운트 예제입니다!</h3>
      <p>현재 카운트 : {count}</p>
      <button onClick={onPlusButtonClickHandler}>+</button>
      <button onClick={onMinusButtonClickHandler}>-</button>
      <div style={boxesStyle}>
        <Box1 />
        <Box2 />
        <Box3 />
      </div>
    </>
  );
}

export default App;
더보기

Box1.jsx  (색과 이름만 바꿔주면 됨)

import React from "react";

const boxStyle = {
  width: "100px",
  height: "100px",
  backgroundColor: "#91c49f",
  color: "white",

  // 가운데 정렬 3종세트
  display: "flex",
  justifyContent: "center",
  alignItems: "center",
};

function Box1() {
  console.log("Box1이 렌더링되었습니다.");
  return <div style={boxStyle}>Box1</div>;
}

export default Box1;

이때 버튼을 눌러주면 Box들도 모두 리렌더링 되는 것을 확인할 수 있다. 이러한 불필요한 리렌더링을 막으려면 Box.jsx에서 `export default` 부분을 다음과 같이 수정해주면 된다.

export default React.memo(Box1);

 

그러면 버튼을 누르면 'App 컴포넌트가 렌더링되었습니다!'만 출력되는 것을 확인할 수 있다.

 

 

3. `useCallback`

이번엔 함수를 캐싱하는 방법에 대해 알아보자.

 

`Box1.jsx`에 count를 초기화하는 버튼을 넣어보자.

// src/App.jsx

...

	// count를 초기화해주는 함수
  const initCount = () => {
    setCount(0);
  };

  return (
    <>
      <h3>카운트 예제입니다!</h3>
      <p>현재 카운트 : {count}</p>
      <button onClick={onPlusButtonClickHandler}>+</button>
      <button onClick={onMinusButtonClickHandler}>-</button>
      <div style={boxesStyle}>
        <Box1 initCount={initCount} />
        <Box2 />
        <Box3 />
      </div>
    </>
  );
}

...
// src/components/Box1.jsx

...

function Box1({ initCount }) {
  console.log("Box1이 렌더링되었습니다.");

  const onInitButtonClickHandler = () => {
    initCount();
  };

  return (
    <div style={boxStyle}>
      <button onClick={onInitButtonClickHandler}>초기화</button>
    </div>
  );
}

...

 

그러면 `+/- 버튼`이나` 초기화 버튼`을 누르면 App 컴포넌트와 Box1 컴포넌트가 리렌더링되는 것을 확인할 수 있다. Box1는 리렌더링될 필요가 없음에도 불구하고 리렌더링된다. 이는 우리가 함수형 컴포넌트를 사용하기 때문에 `App.jsx`가 리렌더링되면서 `onInitButtonClickHandler`도 다시 만들어지기 때문이다. (JS에서 함수도 객체이므로 새로 만들어지면서 주소가 변경됨.)

 

이를 해결하기 위해 `useCallback` hook을 사용한다.

// src/App.jsx

// 변경 전
const initCount = () => {
  setCount(0);
};

// 변경 후
const initCount = useCallback(() => {
  setCount(0);
}, []);

 

변경 후 코드에서 `[]`는 `useEffect`에서 봤던 의존성 배열이다. 이제 `initCount`가 어느 값에도 의존하지 않으므로 `App.jsx`가 리렌더링되지 않는 한 다시 만들어지지 않을 것이다.

 

 

 

4. `useMemo`

아래 코드를 살펴보자.

import React, { useEffect, useState } from "react";

function ObjectComponent() {
  const [isAlive, setIsAlive] = useState(true);
  const [uselessCount, setUselessCount] = useState(0);

  const me = {
    name: "Ted Chang",
    age: 21,
    isAlive: isAlive ? "생존" : "사망",
  };

  useEffect(() => {
    console.log("생존여부가 바뀔 때만 호출해주세요!");
  }, [me]);

  return (
    <>
      <div>
        내 이름은 {me.name}이구, 나이는 {me.age}야!
      </div>
      <br />
      <div>
        <button
          onClick={() => {
            setIsAlive(!isAlive);
          }}
        >
          누르면 살았다가 죽었다가 해요
        </button>
        <br />
        생존여부 : {me.isAlive}
      </div>
      <hr />
      필요없는 숫자 영역이에요!
      <br />
      {uselessCount}
      <br />
      <button
        onClick={() => {
          setUselessCount(uselessCount + 1);
        }}
      >
        누르면 숫자가 올라가요
      </button>
    </>
  );
}

export default ObjectComponent;

 

`isAlive`가 바뀔 때만 '생존여부가 바뀔 때만 호출해주세요!'가 출력되길 기대했지만, 숫자 버튼을 누를 때에도 해당 메세지가 출력된다. 이는 `uselessCount`의 state가 변경됨에 따라 `ObjectComponent`가 리렌더링되어 `me` 객체로 새로운 주소에 할당하기  때문이다. 이를 막기 위해서 `useMemo`에 의존성 배열 `[isAlive]`을 적용해야 한다. 즉 다음과 같이 `me`를 수정해야 한다.

const me = useMemo(() => {
  return {
    name: "Ted Chang",
    age: 21,
    isAlive: isAlive ? "생존" : "사망",
  };
}, [isAlive]);

 

`useMemo` 주의사항

`useMemo`를 남발하게 되면 불필요한 메모리 확보가 많아져 성능이 악화될 수 있다. 따라서 적절히 사용할 수 있어야 한다.