Project/기타

Context Api + Reducer로 전역상태 관리하기 / 아워홈 사전과제

이의찬 2024. 1. 12. 01:39

1. Why?

아워홈 사전과제에서 상품 리스트의 물건을 장바구니에 담는 부분이 있었다. 이를 위해서는 전역 상태에

  • 장바구니에 담겨있는 상품과 수량

이 필요해 보였다. 이를 사용하는 기능으로는

  • 상품 리스트에서 버튼을 누를경우 장바구니에 담김
    • 이미 담겨 있을 경우 수량 + 1
  • 장바구니에서 수량을 조절할 경우 상품의 수량이 변경

정도가 있을텐데, 겨우 이 정도의 상태 관리를 위해서 전역 상태관리 라이브러리를 사용하는 것은 낭비라는 생각이 들었다.

너무 리덕스? 조타이? 사용 하지 마세요.

그래서 React의 네이티브 기능인 Context Api를 통해서 장바구니 상태를 구현했다.


 

2. How?

Context Api란?

일단 Context Api에 대해서 공식문서로 먼저 알아보자.

Context

Context lets a parent—even a distant one!—provide some data to the entire tree inside of it.
context는 멀리 떨어져 있는 상위 트리라도 그 안에 있는 전체 트리에 일부 데이터를 제공할 수 있게 해줍니다.

Context는 단순히 전역 상태만 관리하는게 아니다. 전역으로 멀리 떨어진/다수의 컴포넌트에 데이터를 전달해주는 기능이고, 전역 상태는 그 중 하나일 뿐이다.

context 사용 사례
1. Theming 테마
2. Current account 현재 계정
3. Routing 라우팅
4. Managing state state 관리

공식문서에서도 테마, 현재 계정, 라우팅, state관리 4가지를 context의 사용 사례로 제시하고 있다.

createContext

createContext lets you create a context that components can provide or read.
createContext를 사용하면 컴포넌트가 제공하거나 읽을 수 있는 컨텍스트를 만들 수 있습니다.
const SomeContext = createContext(defaultValue)

 

defaultValue는 컨텍스트를 읽는 컴포넌트 상위 트리에 일치하는 컨텍스트 provider가 없을 때 컨텍스트가 가지는 값이다.

Provider

SomeContext.Provider lets you provide the context value to components.
SomeContext.Provider를 사용하면 컴포넌트에 컨텍스트 값을 제공할 수 있습니다.
function App() {
  const [theme, setTheme] = useState('light');
  // ...
  return (
    <ThemeContext.Provider value={theme}>
      <Page />
    </ThemeContext.Provider>
  );
}​

provider는 value를 통해서 하위 컴포넌트에게 전달되는 값을 정의한다. 컴포넌트가 컨텍스트를 읽을 때는 가까운 상위 트리의 provider에서부터 값을 찾는다.

useContext

useContext is a React Hook that lets you read and subscribe to context from your component.
useContext는 컴포넌트에서 context를 읽고 구독할 수 있게 해주는 React Hook입니다.
SomeContext.Consumer is an alternative and rarely used way to read the context value.
SomeContext.Consumer 는 컨텍스트 값을 읽는 또다른 방법이며 거의 사용되지 않습니다.

useContext VS Consumer

// useContext, ✅ Recommended way
function Button() {
  const theme = useContext(ThemeContext);
  return <button className={theme} />;
}

// Consumer, 🟡 Legacy way (not recommended)
function Button() {
  return (
    <ThemeContext.Consumer>
      {theme => (
        <button className={theme} />
      )}
    </ThemeContext.Consumer>
  );
}

문제점

React automatically re-renders all the children that use a particular context starting from the provider that receives a different value. The previous and the next values are compared with the Object.is comparison. Skipping re-renders with memo does not prevent the children receiving fresh context values.
React는 변경된 value를 받는 provider부터 시작해서 해당 context를 사용하는 자식들에 대해서까지 전부 자동으로 리렌더링합니다. 이전 값과 다음 값은 Object.is로 비교합니다. memo로 리렌더링을 건너뛰어도 새로운 context 값을 수신하는 자식들을 막지는 못합니다.

Provider 내부의 상태값이 변경되면 그 상태값을 구독하는 모든 컴포넌트에 리렌더링이 일어난다. 이 문제점 때문에 자주 상태가 변경되거나, 복잡한 상태를 관리해야한다면 Redux, Jotai, Zustand 같은 상태 관리 라이브러리를 도입하는 게 낫다.

정리

  • Context를 사용해서 전역으로 멀리 떨어진/다수의 컴포넌트에 데이터를 전달할 수 있다.
  • createContext로 컨택스트를 생성한다. createContext는 컨텍스트 객체를 반환한다.
  • SomeContext.Provider를 사용해서 컨텍스트 값을 지정한다.
    • 컨텍스트를 읽는 컴포넌트는 가장 가까운 상위 트리 provider에서부터 값을 찾아나간다.
    • 만약 컨텍스트를 읽는 컴포넌트 상위 트리에 일치하는 컨텍스트 provider가 없다면, 컨택스트를 생성할 때 사용한 default값을 가진다.
  • useContext(someContext)를 호출해 컨텍스트 값을 읽는다.
    • SomeContext.Consumer로도 컨텍스트 값을 읽을 수 있다.
    • 하지만 useContext가 함수형에 특화된 방식이고, 더 간결하기에 공식문서에서는 useContext 방식을 권장한다.
  • Context.Provider 내부의 값이 변경되면 useContext(Context)를 사용하는 컴포넌트도 모두 리렌더링이 일어난다.

그럼 Context Api는 언제 도입해야할까?

  • 자주 변하지 않는 간단한 값만 전달하는 경우
  • 지나치게 많은 props drilling이 일어나고 있을 때
  • 추가적인 라이브러리 없이 리액트 기능만으로 구현하고 싶을때

반대로 아래의 경우에는 다른 방법을 고려해봐야 한다.

  • 상위 컴포넌트의 props 전달로 충분할 때(굳이 전역으로 전달하는 과정이 필요하지 않음)
  • 자주 변하거나 복잡한 값을 전달할 때
  • 복잡한 상태 관리 로직이 필요할 때

3. Reducer와 함께 사용하기

Managing state: As your app grows, you might end up with a lot of state closer to the top of your app. Many distant components below may want to change it. It is common to use a reducer together with context to manage complex state and pass it down to distant components without too much hassle. 
state 관리: 앱이 성장함에 따라 앱 상단에 많은 state가 가까워질 수 있습니다. 아래에 있는 많은 멀리 떨어진 컴포넌트에서 이를 변경하고 싶을 수 있습니다. context와 함께 reducer를 사용하여 복잡한 state를 관리하고 번거로움 없이 멀리 떨어진 컴포넌트에 전달하는 것이 일반적입니다.

Reducer란?

A reducer function is where you will put your state logic. It takes two arguments, the current state and the action object, and it returns the next state:
reducer 함수에 state 로직을 둘 수 있습니다. 이 함수는 두 개의 매개변수를 가지는데, 하나는 현재 state이고 하나는 action 객체입니다. 그리고 이 함수가 다음 state를 반환합니다:

간단하게 이야기하면, state와 action을 매개변수로 받아 새로운 state를 반환해주는 함수다. useReducer를 통해서 useState처럼 상태를 변경하는데 사용할 수 있다.

function reducer(state, action) {
  switch (action.type) {
    case 'incremented_age': {
      return {
        name: state.name,
        age: state.age + 1
      };
    }
    case 'changed_name': {
      return {
        name: action.nextName,
        age: state.age
      };
    }
  }
  throw Error('Unknown action: ' + action.type);
}

우선 state와 action을 입력받고, 돌려줄 state를 계산하는 로직을 case에 맞게 작성해주면 된다. if-else문으로도 작성 가능하지만, reducer만큼은 switch문이 가독성이 뛰어나 대부분 switch문으로 작성한다. 

function Form() {
  const [state, dispatch] = useReducer(reducer, { name: 'Taylor', age: 42 });
  
  function handleButtonClick() {
    dispatch({ type: 'incremented_age' });
  }

  function handleInputChange(e) {
    dispatch({
      type: 'changed_name',
      nextName: e.target.value
    });
  }
  // ...

 

useReducer는 첫 번째 매개변수에는 reducer 함수가, 두 번째는 기본 값이 들어간다. dispatch는 action을 발생시키는 함수다. action 객체의 형식은 따로 정해져 있지는 않지만, type은 어지간하면 넣어주는게 좋다. 그리고 reducer가 다음 상태를 계산할 수 있도록 최소한의 필수 정보는 작성되어야 한다.

useState VS useReducer

useState는 setter를 통해서 간단하게 상태를 바꾸지만, useReducer는 action을 작성해줘야만 한다. 대신 reducer는 순수 함수이기에 테스팅하기에도 좋으며, 컴포넌트에 의존하지 않으니 재사용성도 높여준다.

 


4. 구현

cartContext

import { ReactNode, createContext, useContext, useReducer } from 'react';

import { CartItem } from '@/types/cart';
import { CartAction } from '@/types/context';

import cartReducer from './cartReducer';

const CartStateContext = createContext<CartItem[] | undefined>(undefined);
const CartDispatchContext = createContext<
  React.Dispatch<CartAction> | undefined
>(undefined);

interface CartProviderProps {
  children: ReactNode;
}

export function CartProvider({ children }: CartProviderProps) {
  const [cart, dispatch] = useReducer(cartReducer, []);

  return (
    <CartDispatchContext.Provider value={dispatch}>
      <CartStateContext.Provider value={cart}>
        {children}
      </CartStateContext.Provider>
    </CartDispatchContext.Provider>
  );
}

export const useCartState = () => {
  const context = useContext(CartStateContext);
  if (context === undefined) {
    throw new Error('useCartState must be used within a CartProvider');
  }
  return context;
};

export const useCartDispatch = () => {
  const context = useContext(CartDispatchContext);
  if (context === undefined) {
    throw new Error('useCartDispatch must be used within a CartProvider');
  }
  return context;
};
  • CartStateContext : 쇼핑 카트의 상태 Context 
  • CartDispatchContext : 쇼핑 카트의 상태를 변경하는 함수 Context
  • CartProvider : 내부에 CartDispatchContext.Provider와 CartStateContext.Provider를 가짐
    • useReducer로 cartReducer에서 관리할 상태 cart와 cart를 업데이트하는 dispatch를 선언
    • CartDispatchContext.Provider의 value에 dispatch를 CartStateContext.Provider의 value에 cart를 할당
  • useCartState, useCartDispatch : 각각  CartStateContext와 CartDispatchContext를 가져와서 사용하기 위한 Custom Hook으로, 혹시 CartProvider 외부에서 컴포넌트가 호출될 경우 에러를 출력한다.

하지만 컨텍스트 생성 & 전달만 할 뿐, 관리는 모두 cartReducer에서 한다. 

문제해결

CartStateContext와 CartDispatchContext로 Context를 나눈 이유는 Context api의 주요 문제인 전체 리랜더링 문제 해결을 위해서다. CartStateContext 하나에 dispatch 기능까지 넣었다면, 상태가 변경될 때마다 다음 컴포넌트가 리랜더링된다.

  1. CartStateContext의 상태를 사용하는 컴포넌트
  2. CartStateContext의 상태를 변경시키는(dispatch를 사용한) 컴포넌트

이를 막기위해 CartStateContext와 CartDispatchContext로 state와 dispatch를 나눠서 상태가 변경될 때 1번 컴포넌트만 리랜더링이 일어나도록 막았다.

 

잠깐!

위의 방식으로 문제를 해결한 건, 간단한 상태 관리만을 하기에 가능한 것이다.

  • 나누기도 힘든 복잡한 상태라면?
  • 관리해야할 상태가 많아서 Provider Hell에 빠지게 된다면?

-> 이런 상황이라면 억지로 Context Api를 사용하는게 아니라 전역 상태 관리 라이브러리를 도입하는 것이 맞다.

main

// import...
// router...

ReactDOM.createRoot(document.getElementById('root')!).render(
  <CartProvider>
    <React.StrictMode>
      <RouterProvider router={router} />
    </React.StrictMode>
  </CartProvider>
);

위에서 생성한 CartStateContext와 CartDispatchContext를 전역에서 사용하기위해 RouterProvider 상위에 CartProvider를 선언했다.

cartReducer

import { CartItem } from '@/types/cart';
import { CartAction } from '@/types/context';

/**
 * CartAction에 따라 상태를 업데이트하는 리듀서 함수
 * @param state - 현재 Cart 상태
 * @param action - 수행할 Action
 * @returns {CartItem[]} - 업데이트된 Cart 상태
 */
const cartReducer = (state: CartItem[], action: CartAction): CartItem[] => {
  switch (action.type) {
    case 'ADD_TO_CART': {
      const existingItem = state.find((i) => i.name === action.item.name);
      if (existingItem) {
        return state.map((i) =>
          i.name === action.item.name ? { ...i, quantity: i.quantity + 1 } : i
        );
      }
      return [...state, { ...action.item, quantity: 1 }];
    }
    case 'UPDATE_QUANTITY':
      return state.map((i) =>
        i.name === action.itemName ? { ...i, quantity: action.newQuantity } : i
      );
    default:
      return state;
  }
};

export default cartReducer;

cart 상태관리의 진짜 사장 cartReducer다.

  • ADD_TO_CART : 새로운 아이템을 카트에 추가한다. 이미 카트에 있는 아이템이라면 수량 +1을 한다.
  • UPDATE_QUANTITY : action으로 넘겨받은 itemName의 수량을 업데이트한다.

5. 레퍼런스

https://react-ko.dev/reference/react/useContext

 

useContext – React

The library for web and native user interfaces

react-ko.dev

https://react-ko.dev/learn/passing-data-deeply-with-context

 

context로 데이터 깊숙이 전달하기 – React

The library for web and native user interfaces

react-ko.dev

https://react-ko.dev/learn/scaling-up-with-reducer-and-context

 

Reducer와 Context로 확장하기 – React

The library for web and native user interfaces

react-ko.dev

https://react-ko.dev/learn/extracting-state-logic-into-a-reducer

 

State로직을 Reducer로 추출하기 – React

The library for web and native user interfaces

react-ko.dev

https://velog.io/@velopert/react-context-tutorial#%EC%A0%84%EC%97%AD-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC%EB%8A%94-%EC%96%B8%EC%A0%9C-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8

 

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

여러분, 리액트로 웹 애플리케이션 개발 하면서 Context API를 어떻게 사용하고 계신가요? 과거에도 관련 포스트를 작성한적이 있긴 하지만, 지난 몇 년간 Context를 사용하면서 습득하게된 팁들을

velog.io

https://junjangsee.tistory.com/entry/React-Context-API-useReducer-%EC%A1%B0%ED%95%A9-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0-featTypescript

 

[React] Context API + useReducer 조합 살펴보기 feat. Typescript

리액트 개발을 할 때 상태관리 라이브러리를 사용하지 않으면 무수히 많은 props들과 싸우며 개발을 하게 됩니다.. 😨 그러한 문제를 해결하기 위해 많은 라이브러리(Redux, Recoil, Mobx 등)들이 등장

junjangsee.tistory.com

https://velog.io/@elle2elle/React-%EA%B3%B5%EC%8B%9D%EB%AC%B8%EC%84%9C-Reducer%EC%99%80-Context%EB%A1%9C-%ED%99%95%EC%9E%A5%ED%95%98%EA%B8%B0

 

[React]-공식문서-Reducer와 Context로 확장하기

Reducer를 사용하면 컴포넌트의 state 업데이트 로직을 통합할 수 있습니다. Context를 사용하면 다른 컴포넌트들에 정보를 전달할 수 있습니다. Reducer와 context를 함께 사용하여 복잡한 화면의 state를

velog.io

https://yrnana.dev/post/2021-08-21-context-api-redux/

 

Context API가 존재하지만 여전히 사람들이 redux와 전역 상태관리 라이브러리를 쓰는 이유 | nana.log

context api는 글로벌 상태관리 라이브러리를 대체할 수 없고, 여전히 많은 리액트 개발자들이 redux, mobx 등을 사용하고 있다.

yrnana.dev

https://yceffort.kr/2022/04/deep-dive-in-react-rendering

 

리액트의 렌더링은 어떻게 일어나는가?

리액트에서 렌더링이란, 컴포넌트가 현재 props와 state의 상태에 기초하여 UI를 어떻게 구성할지 컴포넌트에게 요청하는 작업을 의미한다. 렌더링이 일어나는 동안, 리액트는 컴포넌트의 루트에

yceffort.kr