본문 바로가기

개발/프론트엔드 스터디

Context API

이번 글은 전적으로 velopert 님의 블로그 글을 참고했습니다! 해당 블로그 글을 보고 공부하며 메모한 부분과 약간의 생각을 첨부했습니다. 참고 링크는 글 하단에 명시했습니다.

Context는 리액트 컴포넌트 간에 어떠한 값을 공유할 수 있게 해주는 기능이다. 주로 전역적인(global) 값을 다룰 때 사용하지만 꼭 전역적일 필요는 없다. Props가 아닌 또 다른 방식으로 컴포넌트 간에 값을 전달하는 방법인 것이다.

Props로만 값을 전달하면 컴포넌트들 간의 depth가 깊어질 수록 Props Drilling이 심해진다. props를 전달하는 과정에서 실수할 수도 있고, 부모-자식 간이 아닌 형제 컴포넌트 간에는 값의 공유가 어려워진다. Context를 사용하면 이런 어려움들을 해결할 수 있다.

Context 사용법

Context는 리액트 패키지에서 createContext 라는 함수를 불러와서 만들 수 있다.

Context 객체 안에는 Provider라는 컴포넌트가 들어있고, 공유하고자 하는 값을 value라는 Provider의 props로 설정하면 자식 컴포넌트들에서 해당 값에 바로 접근할 수 있게 된다.

import { createContext } from 'react';
const MyContext = createContext();

function App() {
    return (
        <MyContext.Provider value="Hello World!">
            <GrandParent />
        </MyContext.Provider>
    );
}

이렇게 하면, 원하는 컴포넌트에서 useContext라는 Hook을 사용해 Context에 넣은 값에 바로 접근할 수 있게 된다. 해당 Hook의 인자에는 createContext로 만든 컴포넌트인 MyContext를 넣는다.

import { createContext, useContext } from 'react';

function Message() {
  const value = useContext(MyContext);
  return <div>Received: {value}</div>;
}

Context가 여러 컴포넌트에서 사용되고 있다면 다음고 같이 커스텀 Hook을 만드는 것도 좋은 방법이다.

import { createContext, useContext } from 'react';
const MyContext = createContext();

function useMyContext() {
    return useContext(MyContext);
}

function App() {
  return (
    <MyContext.Provider value="Hello World">
      <AwesomeComponent />
    </MyContext.Provider>
  );
}

function AwesomeComponent() {
  return (
    <div>
      <FirstComponent />
      <SecondComponent />
      <ThirdComponent />
    </div>
  );
}

function FirstComponent() {
  const value = useMyContext();
  return <div>First Component says: "{value}"</div>;
}

function SecondComponent() {
  const value = useMyContext();
  return <div>Second Component says: "{value}"</div>;
}

function ThirdComponent() {
  const value = useMyContext();
  return <div>Third Component says: "{value}"</div>;
}

export default App;

Context에서 상태 관리가 필요한 경우

Context는 상태를 관리하는 도구가 아니다. 위에서 말했듯 Props가 아닌 또 다른 방식으로 컴포넌트 간에 값을 전달하는 방법인 것이다. 그러므로 상태를 다른 컴포넌트에 전달할 때는 상태 관리 도구를 사용하고 그것을 Context 안에 넣어 컴포넌트 간에 전달하는 식으로 Context를 사용한다.

Context에서 유동적인 값(상태)을 관리하는 경우에는 Provider를 새로 만들어주는 것이 좋다.

import { createContext, useContext, useMemo, useState } from 'react';

const CounterContext = createContext();

function CounterProvider({ children }) {
  const [counter, setCounter] = useState(1);
  const actions = useMemo(
    () => ({
      increase() {
        setCounter((prev) => prev + 1);
      },
      decrease() {
        setCounter((prev) => prev - 1);
      }
    }),
    []
  );

  const value = useMemo(() => [counter, actions], [counter, actions]);

  return (
    <CounterContext.Provider value={value}>{children}</CounterContext.Provider>
  );
}

function useCounter() {
  const value = useContext(CounterContext);
  if (value === undefined) {
    throw new Error('useCounterState should be used within CounterProvider');
  }
  return value;
}

function App() {
  return (
    <CounterProvider>
      <div>
        <Value />
        <Buttons />
      </div>
    </CounterProvider>
  );
}

function Value() {
  const [counter] = useCounter();
  return <h1>{counter}</h1>;
}

function Buttons() {
  const [, actions] = useCounter();

  return (
    <div>
      <button onClick={actions.increase}>+</button>
      <button onClick={actions.decrease}>-</button>
    </div>
  );
}

export default App;

위 코드에서는 actions라는 객체를 만들어 그 안에 변화를 일으키는 함수들을 넣었다. 그리고 컴포넌트가 리렌더링 될 때마다 함수를 새로 만드는게 아니라 메모이제이션하기 위해 useMemo로 감쌌다.

value에 값을 넣어주기 전에 [counter, action]을 useMemo로 한번 감싸주었다. useMemo로 감싸지 않으면 CounterProvider가 리렌더링 될 때마다 새로운 배열을 만들기 때문에 useContext를 사용하는 컴포넌트 쪽에서 Context 값이 바뀐 것으로 간주해 낭비 렌더링이 발생하기 때문이다.

값과 업데이트 함수를 별개의 Context로 분리하기

Context에서 관리하는 상태가 빈번하게 업데이트 되지 않는다면 위에서 작성한 코드만으로 충분하다. 그러나 상태가 빈번하게 업데이트 된다면, 성능적으로 좋지 않다. 실제로 변화가 반영되는 곳은 Value 컴포넌트 뿐인데, Buttons 컴포넌트도 리렌더링 되기 때문이다.

왜 그럴까? value를 만드는 과정에서 비록 useMemo로 감싸주긴 했지만, 어쨌든 deps 배열 요소인 counter가 바뀔 때마다 새로운 배열을 만들어서 반환하고, useContext에서는 이를 감지해 리렌더링 하기 때문이다.

function Value() {
  console.log('Value');
  const [counter] = useCounter();
  return <h1>{counter}</h1>;
}
function Buttons() {
  console.log('Buttons');
  const [, actions] = useCounter();

  return (
    <div>
      <button onClick={actions.increase}>+</button>
      <button onClick={actions.decrease}>-</button>
    </div>
  );
}

이렇게 짜여진 코드에서 +나 - 버튼을 클릭해 상태가 변하면 두 컴포넌트 모두 리렌더링 된다는 것이다. 이를 최적화하기 위해 수정하는 방법은 간단하다. Context를 하나 더 만들면 된다.

import { createContext, useContext, useMemo, useState } from 'react';

const CounterValueContext = createContext();
const CounterActionsContext = createContext();

function CounterProvider({ children }) {
  const [counter, setCounter] = useState(1);
  const actions = useMemo(
    () => ({
      increase() {
        setCounter((prev) => prev + 1);
      },
      decrease() {
        setCounter((prev) => prev - 1);
      }
    }),
    []
  );

  return (
    <CounterActionsContext.Provider value={actions}>
      <CounterValueContext.Provider value={counter}>
        {children}
      </CounterValueContext.Provider>
    </CounterActionsContext.Provider>
  );
}

function useCounterValue() {
  const value = useContext(CounterValueContext);
  if (value === undefined) {
    throw new Error('useCounterValue should be used within CounterProvider');
  }
  return value;
}

function useCounterActions() {
  const value = useContext(CounterActionsContext);
  if (value === undefined) {
    throw new Error('useCounterActions should be used within CounterProvider');
  }
  return value;
}

function App() {
  return (
    <CounterProvider>
      <div>
        <Value />
        <Buttons />
      </div>
    </CounterProvider>
  );
}

function Value() {
  console.log('Value');
  const counter = useCounterValue();
  return <h1>{counter}</h1>;
}
function Buttons() {
  console.log('Buttons');
  const actions = useCounterActions();

  return (
    <div>
      <button onClick={actions.increase}>+</button>
      <button onClick={actions.decrease}>-</button>
    </div>
  );
}

export default App;

기존 Context 객체를 CounterValueContext와 CounterActionsContext 두 개로 분리했다. 커스텀 Hook 또한 두개로 분리했다.

Context의 활용

Context와 기존 useState 등의 Hook을 활용하면 외부 라이브러리의 도움 없이도 충분히 전역 상태 관리가 가능하다. 아직 적용해본 적은 없지만 모달의 상태를 Context로 관리하면 좋을 것 같다.

모달 상태 관리

const ModalValueContext = createContext();
const ModalActionsContext = createContext();

function ModalProvider({ children }) {
  const [modal, setModal] = useState({
    visible: false,
    message: ''
  });

  const actions = useMemo(
    () => ({
      open(message) {
        setModal({
          message,
          visible: true
        });
      },
      close() {
        setModal((prev) => ({
          ...prev,
          visible: false
        }));
      }
    }),
    []
  );

  return (
    <ModalActionsContext.Provider value={actions}>
      <ModalValueContext.Provider value={modal}>
        {children}
      </ModalValueContext.Provider>
    </ModalActionsContext.Provider>
  );
}

function useModalValue() {
  const value = useContext(ModalValueContext);
  if (value === undefined) {
    throw new Error('useModalValue should be used within ModalProvider');
  }
  return value;
}

function useModalActions() {
  const value = useContext(ModalActionsContext);
  if (value === undefined) {
    throw new Error('useModalActions should be used within ModalProvider');
  }
  return value;
}

이전에 프로젝트를 할 때 모달 상태는 모달을 사용하는 최상단 Page 단에서 선언하고, 그 상태에 따라 모달을 토글하는 식으로 개발했었다. 모달 자체는 재사용 가능한 컴포넌트로 따로 만들었었다.

그래서 props로 복잡하게 넘겨야 했던 기억이 있는데 다음에는 Context로 쉽게 모달 상태를 관리해봐야겠다.

전역 상태 관리 라이브러리와 Context

현대 프론트엔드에서는 redux, recoil 등 상태 관리 라이브러리가 잘 되어 있기 때문에 굳이 Context를 사용하지 않아도 된다고 생각할 수도 있다.

Context를 사용하면, 각기 다른 상태마다 새롭게 Context를 만들어줘야 리렌더링 최적화가 가능한데, Redux 등의 라이브러리는 상태가 업데이트 될 때 실제로 의존하는 값이 바뀔 때만 리렌더링 되도록 자동으로 최적화를 해주기 때문이다.

프로젝트에서 사용해본 Recoil 같은 경우에는 Context를 일일이 만드는 과정을 생략하고 Hook 기반으로 아주 편하게 전역 상태 관리를 할 수 있다.

그러나 최근 서버 상태를 직접 관리하는 tanstack-query 등의 라이브러리가 부상하며, 굳이 몇 개 안되는 클라이언트의 전역 상태만 관리하기 위해 따로 라이브러리를 써야 하는가에 대한 의문이 드는 것도 사실이다.

결국은 선택의 문제이다. 프로젝트에서 관리할 전역 상태가 많고 복잡하다면 상태 관리 라이브러리를 사용하는 것이 좋을 것이다. 그렇지 않다면 Context와 기본 상태 관리 hook을 사용하는 것이 나을 수도 있다. 적절히 판단해 잘 선택해보자.

'개발 > 프론트엔드 스터디' 카테고리의 다른 글

DocumentFragment와 Dollar sign($) 변수명  (0) 2022.02.13