본문 바로가기

내용 복습/Next.js

리액트의 Context API

1. 컨텍스트 API 생성 및 임포트

컨텍스트 API는 컴포넌트나 컴포넌트 레이어 간의 데이터 공유를 용이하게 해줍니다. 컨텍스트 값을 생성하고 해당값을 제공함므로서 이 컨텍스트를 묶어주는데, 다수의 컴포넌트 또는 앱의 모든 컴포넌트를 묶어줄 수 있다. 여러 컴포넌트에 제공되는 컨텍스트 값의 장점은 state와 연결이 쉽다는 점이다.

이는 state를 해당 컨텍스트 값에 연결하면 앱 전체에 제공되는 방식으로 사용된다. 그러므로 props를 없앨 수 있고 컴포넌트 레이어 간의 전달이 필요없게 된다. 대신 상테에 연결된 컨텍스트 값이 앱의 모든 컴포넌트에 제공되는데, 직접적으로 해당 컴포넌트에 필요한 상태값에 연결된다.

 

<ContextName.Provider>와 같은 형태로 사용될 부분을 감싸주는데, Provider 속성은 개발자가 생성한 것이 아닌 리액트가 생성하는 것이다. 관례로 store 폴더 내부에 context api와 관련된 파일을 생성하는데, createContext 메소드를 사용해서 생성한다. 이것은 컨텍스트 객체는 리액트에 의해 생성되고 자동으로 Provider 컴포넌트를 가진다는 것이다.

 

그리고 리액트에서 새로운 훅을 가져와서 컨텍스트를 소비하게 되는데 그것은 바로 useContext이다. 어떤 컴포넌트의 함수라도 컨텍스트 값에 접근해서 사용할 수 있게 해준다. useContext(ContextName);의 형태로 컨텍스트 연결구조가 만들어지고 다른 변수에 할당해서 사용할 수 있게 된다.

 

그리고 context는 provider 컴포넌트에 value 속성을 추가해 줘야 한다. 기본값은 provider 컴포넌트에 의해 쌓여진 컴포넌트가 컨텍스트 value에 접근할 때만 사용된다. 기본값을 설정해 주는 것은 자동완성 기능을 더 쉽게 사용할 수 있게 해준다. 그렇기에 아래와 같은 기본 컨텍스트 값을 설정해 두는 것이 좋고 이 기본값을 구조분해할당해서 useContext의 변수로 선언하는 것도 가능하다.

구조분해할당을 통해 체이닝을 통해 변수를 사용할 때 직접 변수로 사용할 수 있게 되어 코드를 줄일 수 있다.

// context 파일
import { createContext } from 'react'

export const CartContext = createContext({
  items: [],
})

// Cart 컴포넌트
import { useContext } from "react";
import { CartContext } from "../store/shopping-cart-context";

export default function Cart({ onUpdateItemQuantity }) {
const cartCtx = useContext(CartContext)

{cartCtx.items.map((item) => {

// 구조분해할당하면
const { items } = useContext(CartContext)

{items.map((item) => {

 

2. 컨텍스트와 State(상태) 연결하기

아래의 방식으로 상태가 단순히 제공되는 값이 되면 간단하게 연결할 수 있고, 컨텍스트 기능을 이용해 wrap 컴포넌트와 그 자식 컴포넌트에게 제공되는 값이 되었기 때문이다. 하지만 상태 객체 전체를 이렇게 값으로 전달한다면, 읽는 것만 가능하기 때문에 상태를 수정(update)하려면 상태를 수정해야한다.

  const [shoppingCart, setShoppingCart] = useState({
    items: [], // 이 부분은 동적으로 사용할 수 있도록 value로 사용하는 것이 좋다.
  });
  
    <CartContext.Provider value={shoppingCart}>
      <Header
        cart={shoppingCart}
        onUpdateCartItemQuantity={handleUpdateCartItemQuantity}
      />
      <Shop>
      {DUMMY_PRODUCTS.map((product) => (
        <li key={product.id}>
          <Product {...product} onAddToCart={handleAddItemToCart} />
        </li>
        ))}
      </Shop>
    </CartContext.Provider>

 

 

아래에 보이는 것처럼 addItemToCart라는 프로퍼티에 컨텍스트를 통해 handleAddItemToCart 함수를 노출시킬 수 있다. Provider 컴포넌트로 묶여있거나, wrap된 자식 컴포넌트의 경우 addItemToCart 속성을 통해 이 App 컴포넌트에 존재하는 함수의 기능을 불러올 수 있게 된다.

// App 컴포넌트

  function handleAddItemToCart(id) {
   ....
      return {
        items: updatedItems,
      };
    });
  }

const ctxValue = {
    items: shoppingCart.items, // 상태의 items 배열
    addItemToCart: handleAddItemToCart
  }
  
    return (
    <CartContext.Provider value={ctxValue}>

 

그래서 ctxValue라는 상수를 제공할 수 있고, App컴포넌트에서 Product 컴포넌트로 전달되는 불필요한 props인 onAddToCart를 없애고 직접 컨텍스트에 접근해서 addItemToCart를 아래와 같이 구조분해할당으로 가져와서 App 컴포넌트의 handleAddItemToCart 함수를 사용할 수 있게 되는 것이다.

import { useContext } from "react";
import { CartContext } from "../store/shopping-cart-context";

export default function Product({
  id,
  image,
  title,
  price,
  description,
}) {
  const { addItemToCart } = useContext(CartContext)
  return (
    <article className="product">
      <img src={image} alt={title} />
      <div className="product-content">
        <div>
          <h3>{title}</h3>
          <p className='product-price'>${price}</p>
          <p>{description}</p>
        </div>
        <p className='product-actions'>
          <button onClick={() => addItemToCart(id)}>Add to Cart</button>
        </p>
      </div>
    </article>
  );
}

 

그리고 이 과정에서 context로 돌아와서 비어있는 가짜 함수인 기본값을 넣어주면 자동완성을 활용할 수 있다.

export const CartContext = createContext({
  items: [],
  addItemToCart: () => {}
})

 

3. 컨텍스트를 소비하는 다른 방법

useContext를 사용하는 것이 컴포넌트 안의 컨텍스트에 접근할 때 일반적으로 사용하는 방법이다. 우리가 앞서 사용했던 Provider 컴포넌트는 컨텍스트 값을 필요로 하는 모든 타 컴포넌트에 이를 제공하기 위해 사용한다.

하지만 어떤 컨텍스트 객체라도 사용할 수 있는 또 다른 컴포넌트를 제공하는데, Consumer 컴포넌트이다. 컨텍스트 값에 대한 액세스를 가진 JSX 코드를 wraping하는 데에 사용한다. 이 컴포넌트는 특수한 자식 속성을 필요로 하는데, JSX 코드가 아닌 자바스크립트식 표현의 함수를 아래와 같이 추가하면 컨택스트 값을 매개변수로 받게 되고 해당 컴포넌트가 출력해야 하는 JSX 코드를 반환한다. 길고 복잡하여 사용하기에 적합하진 않지만, 레거시코드에서 사용되었을 수 있기에 마주치면 이해할 수 있게 기록하였다.

    <CartContext.Consumer>
      {(cartCtx) => {
        const totalPrice = cartCtx.items.reduce(
          (acc, item) => acc + item.price * item.quantity,
          0
        );
        const formattedTotalPrice = `$${totalPrice.toFixed(2)}`;
        return (
          <div id="cart">
            {cartCtx.items.length === 0 && <p>No items in cart!</p>}
            {cartCtx.items.length > 0 && (
            ........

 

마지막으로 다른 상태값을 각각의 다른 컨텍스트로 공유해야 하는 상황이 생길수도 있는데, 그렇게 되면 컨텍스트를 사용할 모든 컴포넌트에 대한 접근 권한을 가지고 있는 컴포넌트는 root(=App) 컴포넌트이므로 많은 양의 로직이 쌓인다. 이것을 방지하기 위해 앱컴포넌트가 아닌 별개의 컨텍스트 컴포넌트를 만드는 것이 좋다

컨텍스트의 생성이나 공유와는 별개로 컴포넌트 함수를 생성하고 공유할 수 있는데, 아래 추가된 함수의 목적은 단지 앱 컴포넌트 내에 있던 모든 상태와 컨텍스트 값의 관리코드를 이 파일로 가져올 뿐이다. 

 

아래에서 볼 수 있듯이 App 컴포넌트의 함수 두개와 ctxValue를 옯겨온 후 children 프롭스를 Provider로 감싼 값을 반환해준다. 그리고 이 커스텀 컴포넌트로 App 컴포넌트의 리턴문을 감싸주면 App 컴포넌트의 코드를 훨씬 간소화하고 중심로직을 Context 파일로 분리해서 관리할 수 있게 된다.

// shopping-cart-context.jsx
export default function CartContextProvider({children}) {
  const [shoppingCart, setShoppingCart] = useState({
    items: [], // 이 부분은 동적으로 사용할 수 있도록 value로 사용하는 것이 좋다.
  });

  function handleAddItemToCart(id) {
............
  }

  function handleUpdateCartItemQuantity(productId, amount) {
............
  }

  const ctxValue = {
    items: shoppingCart.items,
    addItemToCart: handleAddItemToCart,
    updateItemQuantity: handleUpdateCartItemQuantity,
  };

  return <CartContext.Provider value={ctxValue}>
    {children}
  </CartContext.Provider>;
}
// App
    <CartContextProvider>
      <Header
        cart={shoppingCart}
        onUpdateCartItemQuantity={handleUpdateCartItemQuantity}
      />
      <Shop>
        {DUMMY_PRODUCTS.map((product) => (
          <li key={product.id}>
            <Product {...product} onAddToCart={handleAddItemToCart} />
          </li>
        ))}
      </Shop>
    </CartContextProvider>

 

4. useReducer

리듀서란 하나 또는 그 이상의 복잡한 값을 더 단순한 형태로 만드는 함수이다. 예를 들면 숫자의 집합체를 합쳐서 하나의 함수로 만드는 등의 방식이다.

useReducer는 하나 훅은 상태관리의 목적으로 하나 또는 그 이상의 값을 보다 단순하게 하나의 값으로 줄이는 것을 말한다

 

useReducer의 첫번째 인자는 state와 같이 상태값을 지니는 대상 상태지만, 두번째 인자는 state의 상태 변경인자가 아닌 dispatch 함수이다. 이 함수를 통해 action을 보낼 수 있는데, 이 동작이 리듀서에서 사용되는 주요 함수라고 보면 된다. 그런 다음 그 함수 밖에 새로운 리듀서함수를 정의하는데, 앞서 만든 디스패치 함수가 실행될 때마다 이 새로운 함수가 재생성되지 않게 하기 위해서이고, 또한 디스패치 함수에 의해 정의, 수정되는 어떤 값에도 직접적인 액세스를 요하지 않기 때문이다.

그리고 이 리듀서함수는 state, action 두개의 매개변수를 받는다. action이 디스패치 함수를 통해 보내진 후에 리액트에서 리듀서함수를 호출하기 때문이다.  반면, state는 useReducer로 관리되는 상태의 최신 스냅샷이다. 그리고 이 리듀서함수가 곧 useReducer의 인자가 된다. 이렇게 되면 리액트에 리듀서함수가 등록되고 디스패치함수를 호출할 때 실행되는 것이다. 여기까지가 리듀서함수를 useReducer에 연결하는 단계이다.

 

function shoppingCartReducer(state, action) {
  return state;
}

export default function CartContextProvider({ children }) {
  const [shoppingCartState, shoppingCartDispatch] = useReducer(shoppingCartReducer);

 

그리고 useReducer의 두번째 인자로 상태를 한번도 업데이트 하지 않은 상황의 상태값을 기본값으로 설정할 수 있다. 이는 useState와 동일하다. 대상 상태였던 shoppingCart의 기본값인 items 배열을 기본값으로 가져왔다. 그러면 첫번째 인자는 useReducer의 기능이고 두번째 인자는 기본값이 되면서 useReducer의 구성요소가 갖춰졌다.

  const [shoppingCartState, shoppingCartDispatch] = useReducer(shoppingCartReducer, 
    {
      items: [],
    }
  );

 

그 다음 useState에서 set인자를 사용하듯 dispatch인자를 사용하게 되는데, 이때 매개변수를 type과 같은 속성의 객체를 사용하는 경우가 많은데 이는 액션을 구분하기 위함이다. 그리고 아래의 코드에서 볼 수 있듯 두 함수를 리듀서함수의 if 블럭에 넣고 state를 업데이트하고 useReducer의 첫번째 인자인 상태값은 ctxValue에서 사용되게 된다. 최종 코드는 아래와 같다.

function shoppingCartReducer(state, action) {
  if (action.type === "ADD_ITEM") {
    const updatedItems = [...state.items];

    const existingCartItemIndex = updatedItems.findIndex(
      (cartItem) => cartItem.id === action.payload
    );
.............
    return {
      ...state, // 필수는 아니지만 데이터 유실을 방지하려면 넣는게 좋음.
      items: updatedItems,
    };
  }
  if (action.type === "UPDATE_ITEM") {
    const updatedItems = [...state.items];
    const updatedItemIndex = updatedItems.findIndex(
      (item) => item.id === action.payload.productId
    );
...........
    updatedItem.quantity += action.payload.amount;
...........
    return {
      ...state,
      items: updatedItems,
    };
  }
  return state;
}

export default function CartContextProvider({ children }) {
  const [shoppingCartState, shoppingCartDispatch] = useReducer(
    shoppingCartReducer,
    {
      items: [],
    }
  );

  function handleAddItemToCart(id) {
    shoppingCartDispatch({
      type: "ADD_ITEM",
      payload: id,
    });
  }

  function handleUpdateCartItemQuantity(productId, amount) {
    shoppingCartDispatch({
      type: "UPDATE_ITEM",
      payload: {
        productId,
        amount,
      },
    });
  }

  const ctxValue = {
    items: shoppingCartState.items,
    addItemToCart: handleAddItemToCart,
    updateItemQuantity: handleUpdateCartItemQuantity,
  };

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