다른 사람들이 안 알려주는 리액트에서 Context API 잘 쓰는 방법

velopert·2022년 6월 12일
179
post-thumbnail

여러분, 리액트로 웹 애플리케이션 개발 하면서 Context API를 어떻게 사용하고 계신가요? 과거에도 관련 포스트를 작성한적이 있긴 하지만, 지난 몇 년간 Context를 사용하면서 습득하게된 팁들을 여러분들에게 공유하면서 튜토리얼을 최신화 해보려고 합니다. 리액트를 실무에서 사용하고 계신 분들이라면 대부분 알만한 내용이지만 이에 대해서 입문자를 대상으로 다루는 콘텐츠를 찾기 어렵더라구요! 과거의 제가 잘 몰라서 나누지 못했던 내용들을 여러분들에게 전해드립니다.

이 포스트는 리액트의 Context를 처음 접하는 입문자들을 대상으로 작성하였으며, 책 『리액트를 다루는 기술』 에 수록될 예정입니다.

Context 란?

Context는 리액트 컴포넌트간에 어떠한 값을 공유할수 있게 해주는 기능입니다. 주로 Context는 전역적(global)으로 필요한 값을 다룰 때 사용하는데요, 꼭 전역적일 필요는 없습니다. Context를 단순히 "리액트 컴포넌트에서 Props가 아닌 또 다른 방식으로 컴포넌트간에 값을 전달하는 방법이다" 라고 접근을 하시는 것이 좋습니다.

Props로만 데이터를 전달하면 발생할 수 있는 문제

리액트 애플리케이션에서는 일반적으로 컴포넌트에게 데이터를 전달해주어야 할 때 Props를 통해 전달합니다. 그런데 깊숙히 위치한 컴포넌트에 데이터를 전달해야 하는 경우에는 여러 컴포넌트를 거쳐 연달아서 Props를 설정해주어야 하기 때문에 불편하고 실수할 가능성이 높아지죠.

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

function GrandParent({ value }) {
  return <Parent value={value} />;
}

function Parent({ value }) {
  return <Child value={value} />;
}

function Child({ value }) {
  return <GrandChild value={value} />;
}

function GrandChild({ value }) {
  return <Message value={value} />;
}

function Message({ value }) {
  return <div>Received: {value}</div>;
}

이러한 코드를 Props Drilling 이라고 부릅니다. 컴포넌트를 한 두개정도 거쳐서 Props를 전달하는거라면 괜찮지만 이렇게 4개정도를 거쳐서 전달하게 된다면, 너무 불편할 것입니다. 예를 들어서 Message 컴포넌트를 열어서, 이 value 값이 어디서 오는건지 파악하려고 한다면 그 상위 컴포넌트로 타고 또 타고 거슬러 올라가야 하기 때문에 매우 불편합니다. 또는, value 라는 네이밍을 message 로 변경을 하고 싶어진다면, 통일성을 맞추기 위해서 또 여러 컴포넌트들을 수정해야 하니까 그것도 그것대로 불편합니다.

또 다른 예시를 확인해볼까요? 우리가 컴포넌트를 만들 때, 여러 종류의 자식 컴포넌트가 특정 값에 의존을 한다고 가정을 해봅시다. 다음과 같이 말이죠.

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

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

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

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

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

지금의 경우에는 각 컴포넌트가 하나의 Props만 받아오기 때문에 많이 복잡해보이지 않을 수 있지만, 만약 각 컴포넌트에 더 다양한 Props가 들어있고, 컴포넌트도 더 다양하고 구조가 좀 더 까다로웠다면, 가독성이 떨어질 수 도 있습니다.

앞서 언급한 문제들은 Context 를 사용하면 깔끔하게 해결할 수 있습니다.

Context 사용법

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

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

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

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>;
}

이렇게 하면 중간 중간 여러 컴포넌트를 거쳐 전달하던 Props를 지워주어도 되지요. 이제 전체 코드를 확인해볼까요?

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

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

function GrandParent() {
  return <Parent />;
}

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

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

function GrandChild() {
  return <Message />;
}

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

export default App;

코드 링크

어떤가요, 훨씬 깔끔하죠?

아까전에 봤던 두번째 코드 예시에도 Context를 적용한 예시를 확인해 봅시다.

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

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

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

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

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

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

export default App;

AwesomeComponent에서 내부의 각 컴포넌트들에게 Props를 설정해주는 것이 아니라, 각 컴포넌트에서 원하는 값을 useContext로 가져오는 방식으로 바뀌었습니다.

위 코드처럼 만약 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;

코드 링크

Provider를 사용하지 않을 경우

만약 자식 컴포넌트에서 useContext 를 사용하고 있는데, Provider 컴포넌트로 감싸는 것을 깜빡하면 어떻게 될까요?

value 값을 따로 지정하지 않았기 때문에, undefined로 조회되어 해당 값이 보여질 자리에 아무것도 나타나지 않게됩니다.

그러한 경우에 기본 값을 설정하고 싶다면, createContext 함수에 인자로 기본 값을 넣어주시면 됩니다.

const MyContext = createContext('default value');

기본 값을 보여주지 않고 아예 오류를 띄워서 개발자가 고치도록 명시를 하고 싶다면 아까 우리가 만든 커스텀 Hook을 다음과 같이 수정하면 됩니다.

const MyContext = createContext();

function useMyContext() {
  const value = useContext(MyContext);
  if (value === undefined) {
    throw new Error('useMyContext should be used within MyContext.Provider');
  }
}

코드 링크

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

이번에는 Context 에서 유동적인 값을 다뤄야 할 때는 어떻게 해야 하는지 알아보겠습니다.

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

function Value() {
  return <h1>1</h1>;
}
function Buttons() {
  return (
    <div>
      <button>+</button>
      <button>-</button>
    </div>
  );
}

export default App;

이렇게, 숫자가 보여지는 UI와 숫자에 변화를 주는 UI가 완전히 다른 컴포넌트로 분리되어 있고 이를 Props로 함수나 값을 전달하는 것이 아니라 Context를 사용하여 구현을 해보겠습니다.

우선, Context 에서 유동적인 값을 관리하는 경우엔 Provider를 새로 만들어주는 것이 좋습니다. 다음과 같이 말이죠

import { createContext } from 'react';

const CounterContext = createContext();

function CounterProvider({ children }) {
  return <CounterContext.Provider>{children}</CounterContext.Provider>;
}

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

// (...)

위와 같이 children Props를 받아와서 CounterContext.Provider 태그 사이에 넣어주시면 됩니다. 그 다음엔, 필요한 기능들을 CounterProvider 컴포넌트 안에서 구현해주면 됩니다.

지금과 같이 하나의 상태만 있는 경우라면, useState를 사용하여 만들어진 값과 함수가 들어있는 배열을 통째로 value 로 넣으세요.

import { createContext, useState } from 'react';

const CounterContext = createContext();

function CounterProvider({ children }) {
  const counterState = useState(1);
  return (
    <CounterContext.Provider value={counterState}>
      {children}
    </CounterContext.Provider>
  );
}

// (...)

그 다음에는 useCounterState 라는 커스텀 Hook을 이렇게 만들어보세요.

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

// (...)

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

이렇게 Hook을 준비해주고 나면, CounterProvider 의 자식 컴포넌트 어디서든지 useCoutnerState 를 사용하여 값을 조회하거나 변경할 수 있습니다.

Value 컴포넌트와 Buttons 를 다음과 같이 수정해보세요.

// (...)

function Value() {
  const [counter] = useCounterState();
  return <h1>{counter}</h1>;
}
function Buttons() {
  const [, setCounter] = useCounterState();
  const increase = () => setCounter((prev) => prev + 1);
  const decrease = () => setCounter((prev) => prev - 1);

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

export default App;

ValueButtons 컴포넌트는 이제 Props로 데이터나 함수를 부모 컴포넌트에게서 전달받지 않고, Context에 직접 접근하여 CounterProvider 의 상태를 조회하고 변경을 할 수 있게 되었습니다.

코드 링크

여기서 만약에 데이터를 어떻게 업데이트할 지에 대한 로직을 컴포넌트가 아니라 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의 값이 바뀐 것으로 간주하게 되어 낭비 렌더링이 발생하게 됩니다.

물론 지금의 경우엔 CounterProviderApp에서 최상위 컴포넌트이기 때문에 큰 의미가 없는 최적화이긴 하지만 평상시에 이러한 습관을 가지는게 앞으로 개발할 때 도움이 됩니다.

그리고 우리가 이전에 만들었던 커스텀 Hook의 경우엔 useCounter 로 이름을 변경해주었습니다. 이전에는 반환하는 값이 useState 와 동일했지만 이젠 커스터마이즈 되었기 때문에 이름을 변경해주어야 나중에 헷갈리지 않습니다.

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

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

왜 그럴까요? 우리가 비록 value를 만드는 과정에서 useMemo로 감싸주었긴 하지만, 어쨌든 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;

코드 링크

기존의 CounterContextCounterValueContextCounterActionsContext로 분리해주었습니다. 그리고, 두 Provider를 모두 사용해주었지요. 커스텀 Hook 또한 두개로 분리했습니다.

이렇게 하고나면, 아까의 문제점이 해결됩니다. 이제 버튼을 눌러서 상태에 변화가 일어날 때, Value 컴포넌트에서만 리렌더링이 발생합니다.

우리는 상태를 다루는 Context를 활용하는 과정에서 여러 업데이트 함수가 들어있는 actions 객체를 선언하여 별도의 Context에 넣어주는 방식으로 구현을 해주었습니다. 이 방식 외에도, useReducer롤 통해서 상태 업데이트를 하도록 구현하고 dispatch를 별도의 Context로 넣어주는 방식도 있습니다. 이러한 방식은 Kent C Dodds 블로그에서 소개 됐었고 꽤나 유용하지요.

저는 useReducer를 사용하는 것 보다 이 글에서 다뤘던 것 처럼 업데이트 함수들이 들어있는 객체를 바로 선언하는것을 선호하는데요, 그 이유는 Context를 사용하는 측에서 더욱 편하기도 하고, 굳이 액션 객체 및 리듀서 함수를 만들지 않아도 되어서 업데이트 함수의 파라미터를 자유롭고 편하게 설정할 수 있어서 편하기 때문입니다.

Context의 상태에서 배열이나 객체를 다루는 경우

방금 우리가 사용했던 방식은 배열이나 객체를 다룰 때에도 동일합니다. 다른 예시들을 보면서 Context를 어떻게 활용할 수 있는지 알아봅시다.

예를 들어서 화면의 중앙에 문구를 띄우는 모달의 상태를 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;
}

이렇게 하면 원하는 곳 어디서든지 다음과 같이 모달을 띄울 수 있겠죠.

const { open } = useModalActions();

const handleSomething = () => {
  open('안녕하세요!');
};

만약 할 일 목록같은 배열을 다룬다면 어떨까요?

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

const TodosValueContext = createContext();
const TodosActionsContext = createContext();

function TodosProvider({ children }) {
  const idRef = useRef(3);
  const [todos, setTodos] = useState([
    {
      id: 1,
      text: '밥먹기',
      done: true
    },
    {
      id: 2,
      text: '잠자기',
      done: false
    }
  ]);

  const actions = useMemo(
    () => ({
      add(text) {
        const id = idRef.current;
        idRef.current += 1;
        setTodos((prev) => [
          ...prev,
          {
            id,
            text,
            done: false
          }
        ]);
      },
      toggle(id) {
        setTodos((prev) =>
          prev.map((item) =>
            item.id === id
              ? {
                  ...item,
                  done: !item.done
                }
              : item
          )
        );
      },
      remove(id) {
        setTodos((prev) => prev.filter((item) => item.id !== id));
      }
    }),
    []
  );

  return (
    <TodosActionsContext.Provider value={actions}>
      <TodosValueContext.Provider value={todos}>
        {children}
      </TodosValueContext.Provider>
    </TodosActionsContext.Provider>
  );
}

function useTodosValue() {
  const value = useContext(TodosValueContext);
  if (value === undefined) {
    throw new Error('useTodosValue should be used within TodosProvider');
  }
  return value;
}

function useTodosActions() {
  const value = useContext(TodosActionsContext);
  if (value === undefined) {
    throw new Error('useTodosActions should be used within TodosProvider');
  }
  return value;
}

이렇게 하면 할 일 항목을 추가할때는:

const { add } = useTodosActions();

const handleSubmit = () => {
  add(text);
}

그리고 각 항목을 보여주는 컴포넌트에서는:

const { toggle, remove } = useTodosActions()

const handleToggle = () => {
  toggle(id);
};

const handleRemove = () => {
  remove(id);
};

이런 식으로 구현해줄 수 있겠지요? 과거에는 위와 같은 작업을 하기 위하여 useReducer를 사용하기도 했었는데, 개인적으로 굳이 액션/리듀서 방식을 사용하는건 불편하다는 생각이 들어서 위와 같은 방법이 더욱 좋다고 생각합니다.

Context가 꼭 전역적이어야 한다는 생각을 버리자

Context에서 다루는 값은 꼭 전역적일 필요가 없습니다. Context는 재사용성이 높은 컴포넌트를 만들때도 매우 유용합니다.

다음 코드를 한번 확인해보세요.

import { useState } from 'react';

function Item({ active, children, onClick }) {
  const activeStyle = {
    backgroundColor: 'black',
    color: 'white'
  };
  const style = {
    cursor: 'pointer',
    padding: '1rem'
  };
  return (
    <div
      style={active ? { ...style, ...activeStyle } : style}
      onClick={onClick}
    >
      {children}
    </div>
  );
}

function App() {
  const [activeId, setActiveId] = useState(1);
  return (
    <div>
      <Item active={activeId === 1} onClick={() => setActiveId(1)}>
        Hello
      </Item>
      <Item active={activeId === 2} onClick={() => setActiveId(2)}>
        World
      </Item>
      <Item active={activeId === 3} onClick={() => setActiveId(3)}>
        React
      </Item>
    </div>
  );
}

export default App;

코드 링크
위 스크린샷 처럼, 항목을 선택할 수 있는 기능을 만들었는데요, active 값을 설정하기 위해서 각 컴포넌트마다 id를 비교하고, onClick도 각 id에 따라 다르게 새로운 함수를 선언하여 설정해주었습니다.

이걸 아예 id 를 Props로 넣는 방식으로 리팩토링을 한다고 해도 그리 컴포넌트를 사용할 떄 가독성이 그리 만족스럽지는 않습니다.

import { useState } from 'react';

function Item({ activeId, children, onSelect, id }) {
  const activeStyle = {
    backgroundColor: 'black',
    color: 'white'
  };
  const style = {
    cursor: 'pointer',
    padding: '1rem'
  };
  const active = activeId === id;
  const onClick = () => onSelect(id);
  return (
    <div
      style={active ? { ...style, ...activeStyle } : style}
      onClick={onClick}
    >
      {children}
    </div>
  );
}

function App() {
  const [activeId, setActiveId] = useState(1);
  return (
    <div>
      <Item id={1} activeId={activeId} onSelect={setActiveId}>
        Hello
      </Item>
      <Item id={2} activeId={activeId} onSelect={setActiveId}>
        World
      </Item>
      <Item id={3} activeId={activeId} onSelect={setActiveId}>
        React
      </Item>
    </div>
  );
}

export default App;

코드 링크
사용할때마다 activeId, onSelect Props를 반복적으로 넣어줘야 하는게 조금 불편하게 느껴집니다. 물론 다음과 같이 배열의 map 내장함수를 사용한다면 반복되는 코드는 사라지겠지만, 항목들을 JSX로 명료하게 표현해내지 못한다는 점이 아쉬울 수 있습니다.

function App() {
  const [activeId, setActiveId] = useState(1);
  const items = [
    { id: 1, text: 'Hello' },
    { id: 2, text: 'World' },
    { id: 3, text: 'React' }
  ];
  return (
    <div>
      {items.map((item) => (
        <Item
          key={item.id}
          id={item.id}
          activeId={activeId}
          onSelect={setActiveId}
        >
          {item.text}
        </Item>
      ))}
    </div>
  );
}

코드 링크

만약에 항목들을 JSX로 표현하고 싶고, 반복되는 코드들을 정리해주고 싶다면, 이 또한 Context를 사용하여 쉽게 해결할 수 있습니다.

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

const ItemGroupContext = createContext();
function ItemGroup({ children, activeId, onSelect }) {
  const value = useMemo(
    () => ({
      activeId,
      onSelect
    }),
    [activeId, onSelect]
  );
  return (
    <ItemGroupContext.Provider value={value}>
      {children}
    </ItemGroupContext.Provider>
  );
}
function useItemGroup() {
  const value = useContext(ItemGroupContext);
  if (value === undefined) {
    throw new Error('Item component should be used within ItemGroup');
  }
  return value;
}

function Item({ children, id }) {
  const activeStyle = {
    backgroundColor: 'black',
    color: 'white'
  };
  const style = {
    cursor: 'pointer',
    padding: '1rem'
  };
  const { activeId, onSelect } = useItemGroup();
  const active = activeId === id;
  const onClick = () => onSelect(id);
  return (
    <div
      style={active ? { ...style, ...activeStyle } : style}
      onClick={onClick}
    >
      {children}
    </div>
  );
}

function App() {
  const [activeId, setActiveId] = useState(1);
  const [anotherActiveId, setAnotherActiveId] = useState(4);

  return (
    <div>
      <ItemGroup activeId={activeId} onSelect={setActiveId}>
        <Item id={1}>Hello</Item>
        <Item id={2}>World</Item>
        <Item id={3}>React</Item>
      </ItemGroup>
      <hr />
      <ItemGroup activeId={anotherActiveId} onSelect={setAnotherActiveId}>
        <Item id={4}>Bye</Item>
        <Item id={5}>World</Item>
        <Item id={6}>Context</Item>
      </ItemGroup>
    </div>
  );
}

export default App;

필요한 값과 함수를 매번 Props로 넣어주는 대신, ItemGroup 이라는 Provider 컴포넌트를 만들어서 해당 컴포넌트에만 한번 넣어주고 Item 에서 Context를 읽어와서 값을 사용하도록 만들어주었습니다.

비록 작성해야 할 전체적인 코드는 조금 늘어났지만, Item 코드를 사용하는 쪽에서는 훨씬 가독성 높고 편하게 작성할 수 있고 재사용성 또한 좋아졌습니다.

이렇게, Context 를 꼭 전역적인 값을 위해서만 쓰는게 아니라, Props가 아닌 또 다른 방식으로 데이터를 전달하는 것이라는 접근을 하여 사용한다면 다양한 상황에 유용하게 쓸 수 있답니다.

전역 상태 관리 라이브러리는 언제 써야 할까?

리액트를 사용하여 웹 애플리케이션을 구현하면서, Redux, MobX, Recoil, Jotai, Zustand 등의 다양한 전역 상태 관리 라이브러리를 사용하곤 합니다.

과거에는 리액트의 Context가 굉장히 불편해서 전역 상태 관리 라이브러리를 사용하는 것이 당연시 여겨졌던 시절이 있었지만 이제는 사용하기 편해져서 단순히 전역적인 상태를 관리하기 위함이라면 더 이상 사용해야 할 이유가 없습니다.

단, "상태 관리 라이브러리"와 Context는 완전히 별개의 개념임을 잘 이해하셔야 합니다. Context는 전역 상태 관리를 할 수 있는 수단일 뿐이고, 상태 관리 라이브러리는 상태 관리를 더욱 편하고, 효율적으로 할 수 있게 해주는 기능들을 제공해주는 도구입니다.

예를 들어, Redux의 경우에는 액션과 리듀서라는 개념을 사용하여 상태 업데이트 로직을 컴포넌트 밖으로 분리 할 수 있게 해주며, 상태가 업데이트 될 때 실제로 의존하는 값이 바뀔 때만 컴포넌트가 리렌더링 되도록 최적화를 해줍니다. 만약 Context를 쓴다면, 각기 다른 상태마다 새롭게 Context를 만들어주어야 하는데, 이 과정을 생략할 수 있기에 편리하죠.

MobX의 경우엔 Redux와 마찬가지로 상태 업데이트 로직을 컴포넌트 밖으로 분리할 수 있게 해주고, 함수형 리액티브 프로그래밍 방식을 도입하여 mutable한 상태가 리액트에서도 잘 보여지게 해주고 상태 업데이트 로직을 더욱 편하게 작성할 수 있게 해주며 최적화도 잘 해줍니다.

Recoil, Jotai, Zustand의 경우엔 Context를 일일이 만드는 과정을 생략하고 Hook 기반으로 아주 편하게 전역 상태 관리를 할 수 있게 해주죠. 최적화 기능 또한 덤으로 딸려오고요.

전역 상태 라이브러리는 결국 상태 관리를 조금 더 쉽게 하기 위해서 사용하는 것이며 취향에 따라 선택해서 사용하면 되는 것입니다.

profile
Frontend Engineer@Laftel Inc. 개발을 재미있게 이것 저것 하는 개발자입니다.

9개의 댓글

comment-user-thumbnail
2022년 6월 14일

오랜만에 정독했네요. 감사합니다.

답글 달기
comment-user-thumbnail
2022년 6월 15일

유익한 좋은 글 감사합니다! 프로젝트에 적용시켜봐야겠어요 😄

답글 달기
comment-user-thumbnail
2022년 6월 15일

좋은 글 감사합니다. 정독하고 갑니다 :)

답글 달기
comment-user-thumbnail
2022년 6월 15일

감사합니다.

답글 달기
comment-user-thumbnail
2022년 6월 17일

글 시작하는 부분부터 너무 좋아서 선 댓글 답니다.
컨텍스트 API가 잠깐 리덕스로부터 흐름을 넘겨줬다고는 하지만, 리코일이나 조타이 같은 컨텐스트 컨셉 라이브러리가 최근 각광을 받고 있어서, 기초부터 알고 넘어가기에 좋은 개념이라고 생각합니다.

답글 달기
comment-user-thumbnail
2022년 6월 17일

좋은 글 감사합니다.

답글 달기
comment-user-thumbnail
2022년 6월 20일

좋은 글 감사합니다 !!

답글 달기
comment-user-thumbnail
2022년 6월 21일

감사합니다 저도 적용시켜볼게요!!

답글 달기
comment-user-thumbnail
2022년 6월 23일

useMemo를 통해서 method를 외부로 노출한 이유는 무엇일까요?

답글 달기