CS/프로그래밍

Context API

H.E 2024. 1. 1. 22:06

개발을 하다보면 전역상태로 관리할 상태가 많지 않을때 전역상태라이브러리를 쓰는 것보다는 ContextApi를 사용하는 것이 낫지 않을까라는 생각이 들기에 이번에 ContextApi를 파보기로 함

 

[ Context ]

Context는 리액트 컴포넌트 간에 어떠한 값을 공유할수 있게 해주는 기능

주로 전역적(global)으로 필요한 값을 다룰 때 사용하며 꼭 전역적일 필요는 없음

따라서 리액트 컴포넌트에서 Props가 아닌 또 다른 방식으로 컴포넌트간에 값을 전달하는 방법이라고 접근하는 것이 좋음

 

Props로만 데이터를 전달하게 되면 깊숙히 위치한 컴포넌트에 데이터를 전달해야할 경우 여러 컴포넌트를 거쳐 연달아서 Props를 설정해주어야 해 불편하고 가독성이 떨어지고 비효율적이며 실수할 가능성이 높아짐(Props Drilling)

이 문제를 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';
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 <Message />;
}

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

export default App;

 

또한 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 컴포넌트로 감싸지 않게 된다면 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에서 유동적인 상태 관리가 필요한 경우

항상 같은 값을 고정적으로 하는 것이 아닌 유동적으로 변하는 값을 관리하기 위해서는 Provider를 새로 만들어 주는 것이 좋음. 값이 하나의 상태만 있다면 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>
  );
}

// (...)

 

커스텀 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;
}

 

Context로 직접 접근하여 상태를 조회하고 변경할 수 있게 됨

// (...)

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;

 

 

데이터를 어떻게 업데이트할지에 대한 로직을 Provider단에서 구현하고 싶다면 value에 로직도 같이 넣어줌

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로 감싸주었는데 감싸지 않으면 CounterProvider가 리렌더링 될때마다 새로운 배열을 만들기 때문에 useContext를 사용하는 컴포넌트 쪽에서는 Context의 값이 바뀐 것으로 간주되어 낭비 렌더링이 발생하기 때문

 

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

Context에서 관리하는 상태가 빈번하게 업데이트 된다면 값과 업데이트 함수가 있는 위에 예시 코드는 성능상 좋지 않음

이유는 값이 실제로 반영되는 것은 value 컴포넌트이지만 buttons 컴포넌트도 배열이 새롭게 반환되어 리렌더링 하기 때문

 

따라서 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;

 

1개의 Context를 2개의 Context로 나누어 분리함

두 Provide를 모두 사용해주어 커스텀 Hook 또한 2개로 분리함

이제 값과 업데이트 함수가 나뉘어져 버튼을 눌러 상태에 변화가 일어날때 value 컴포넌트에서만 리렌더링이 발생함

 

위에서는 actions 객체를 선언하여 별도의 Context에 넣어주는 방식으로 구현했지만 useReducer를 통해서 상태 업데이트를 하도록 구현하고 dispatch를 별도의 Context로 넣어주는 방식도 있음(Kent C Dodds 블로그)

 

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

 

▶ 전역 상태 관리 라이브러리는 언제 쓰는 것이 좋을까?

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

 

과거에는 Context가 굉장히 불편하여 전역 상태 관리 라이브러리를 사용하는 것이 당연시 여겨졌지만 현재는 사용하기 편해져서 단순히 전역적인 상태를 관리하기 위함이라면 더 이상 사용해야 할 이유가 없어짐

 

상태 관리 라이브러리와 Context는 완전히 별개의 개념임을 잘 이해해야함

Context는 전역 상태 관리를 할 수 있는 수단일 뿐이며 상태 관리 라이브러리는 상태 관리를 더욱 편하고 효율적으로 할 수 있도록 해주는 기능을 제공해주는 도구임

 

Redux의 경우 액션과 리듀서의 개념을 사용하여 상태 업데이트 로직을 컴포넌트 밖으로 분리할 수 있도록 해주며 상태가 업데이트 될때 실제로 의존하는 값이 바뀔때만 컴포넌트가 리렌더링 되도록 최적화 해줌

만약 Context를 사용한다면 다른 상태마다 새롭게 Context를 만들어주어야 하는데 과정을 생략할 수 있어 Redux가 편리

 

Recoil의 경우 Context르 일일이 만드는 과정을 생략하고 Hook 기반으로 아주 편하게 전역 상태 관리를 할 수 있게 해주며 최적화 해줌

 

전역 상태 라이브러리는 결국 상태 관리를 조금 더 쉽게 하기 위해서 사용하는 것이기에 취향에 따라 맞게 사용하면 됨

'CS > 프로그래밍' 카테고리의 다른 글

리액트 Hook  (1) 2024.01.02
스코프, 스코프 체인  (1) 2024.01.02
JS, 이벤트 바인딩(Event Binding)  (0) 2023.12.26
props/state  (0) 2023.12.24
리액트의 라이프사이클  (0) 2023.12.16