Context API, useReducer

고성인·2025년 2월 18일

React

목록 보기
8/17
post-thumbnail

Context와 Prop drilling

리액트 애플리케이션은 기본적으로 부모 컴포넌트와 자식 컴포넌트로 이루어진 트리 구조를 갖고있다.
따라서 부모 컴포넌트에 있는 데이터를 자식 컴포넌트에서 사용하기 위해서는 props를 통해 데이터를 전달해 주어야 한다.
하지만 데이터를 전달하는 컴포넌트와 전달받아 사용하는 컴포넌트의 거리가 멀어질수록 문제가 생긴다.

Prop drilling

<A props={data}>
  <B props={data}>
    <C props={data}>
      <D props={data} />
    </C>
  </B>
</A>

위의 코드와 같이 A에서 제공하는 데이터를 D에서 받아 사용하기 위해서는 props를 하위 컴포넌트로 필요한 위치까지 계속 넘겨주어야 하는데 이를 Props drilling이라 한다.
위의 코드에서 B와 C컴포넌트는 data를 전달하는 역할만 하며, 실제 사용은 하지 않는다.

Context

이러한 Prop drilling을 해결하기 위해 등장한 개념이 바로 Context로, 부모 컴포넌트가 트리 아래에 있는 모든 컴포넌트에 깊이에 상관없이 정보를 명시적으로 props를 통해 전달하지 않고도 사용하게 해준다.

Prop drilling을 해결하기 위한 또다른 방법

prop drilling을 해결하기 위해 사용할 수 있는 또다른 방법은 Component composition(컴포넌트 합성)이 존재한다.
간단히 설명하면 prop drilling이 일어나는 중간 컴포넌트를 wrapper component로 교체한 후 children속성을 사용하는 방법이다.

function App() {
  ...
  ...
  return (
    <>
      <Header cart={shoppingCart} onUpdateCartItemQuantity={handleUpdateCartItemQuantity} />
      <Shop onAddItemToCart={handleAddItemToCart} />
    </>
  );
}

function Shop({ onAddItemToCart }) {
  return (
    <section className="w-[80%] my-8 mx-auto">
      <h2 className="text-2xl text-[#a59b8b] uppercase">Elegant Clothing For Everyone</h2>
      <ul className="list-none m-0 p-0 grid grid-cols-[repeat(auto-fit,_minmax(20rem,_1fr))] gap-8">
        {DUMMY_PRODUCTS.map((product) => (
          <li key={product.id}>
            <Product {...product} onAddToCart={onAddItemToCart} />
          </li>
        ))}
      </ul>
    </section>
  );
}

위의 코드를 살펴보면 App컴포넌트에서 Product컴포넌트까지 handleAddItemToCart()함수를 전달해주고있다.
이때 Shop컴포넌트는 해당함수를 사용하는것이 아닌 Product컴포넌트에 전달만 해주는 역할을 한다.

이러한 경우 사용할 수 있는 방법으로 코드를 다음과 같이 수정하여 prop drilling을 해결한다.

function App() {
  ...
  ...
  return (
    <>
      <Header cart={shoppingCart} onUpdateCartItemQuantity={handleUpdateCartItemQuantity} />
      <Shop>
        {DUMMY_PRODUCTS.map((product) => (
          <li key={product.id}>
            <Product {...product} onAddToCart={handleAddItemToCart} />
          </li>
        ))}
      </Shop>
    </>
  );
}

function Shop({ children }) {
  return (
    <section className="w-[80%] my-8 mx-auto">
      <h2 className="text-2xl text-[#a59b8b] uppercase">Elegant Clothing For Everyone</h2>
      <ul className="list-none m-0 p-0 grid grid-cols-[repeat(auto-fit,_minmax(20rem,_1fr))] gap-8">
        {children}
      </ul>
    </section>
  );
}

Shop컴포넌트를 Product컴포넌트를 포함한 li요소를 감싸는 wrapper 컴포넌트로 설정해준 뒤, Product컴포넌트를 App컴포넌트에서 직접 사용하면서 prop drilling을 해결할 수 있다.

하지만 이러한 방식은 모든 prop drilling상황에서 사용하기에는 적합하지 않다.
왜냐하면 이러한 방식으로 모든 컴포넌트를 구성하면 결국에는 모든 컴포넌트가 App컴포넌트로 들어가고, 나머지 컴포넌트는 wrapper컴포넌트로만 사용되기 때문이다.

Context API

Context API는 React에서 컴포넌트와 컴포넌트 사이의 데이터 공유를 용이하게 해주는 기능이다.
이미지에서 보이는것과 같이 Context를 통해 감싸진 하위 컴포넌트에게 데이터를 props를 통해 넘겨주지 않고 직접 넘겨주는 것이 가능해진다.

createContext

context를 생성할때 사용되는 함수이다.
const SomeContext = createContext(defaultValue)와 같은 형태로 사용되며, 반환값으로 SomeContext.Provider와 SomeContext.Consumer를 갖는다
컨텍스트 자체는 어떠한 정보도 갖고있지 않다!
defaultValue는 실제 컨텍스트의 값은 아니지만, 자동완성기능을 위해 제공해야 한다.

SomeContext.Provider

컴포넌트를 감싸는 역할을 하며, 감싸진 모든 내부 컴포넌트에서 해당 context의 값을 사용할 수 있다.
Props로 value를 가지며, 해당 provider로 감싸진 내부 컴포넌트에 전달하려는 값을 의미한다.

SomeContext.Consumer

현재는 컨텍스트의 값을 읽어오기위해 useContext나 use를 사용하지만, 이전에는 SomeContext.Consumer를 통해 값을 읽어왔다.
현재는 권장되지 않는 방식이다.

<ThemeContext.Consumer>
  {theme => (
    <button className={theme} />
  )}
</ThemeContext.Consumer>

위의 코드와 같이 Props로 함수를 가진다.

TypeScript를 사용한 createContext와 Provider

import { createContext } from "react";

export interface CartContextType extends ShoppingCartType {
  addItemToCart: (id: string) => void;
  updateCartItemQuantity: (productId: string, amount: number) => void;
}

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

위의 코드를 통해 context를 생성하여준다.
그 후 다음과 같은 코드를 통해 컴포넌트를 감싸줄 수 있다.

...
...
const cartValue: CartContextType = {
    items: shoppingCartState.items,
    addItemToCart: handleAddItemToCart,
    updateCartItemQuantity: handleUpdateCartItemQuantity
};
...
...
<CartContext.Provider value={cartValue}>
  <Header />
  <Shop>
    {DUMMY_PRODUCTS.map((product) => (
      <li key={product.id}>
        <Product {...product} />
      </li>
    ))}
  </Shop>
</CartContext.Provider>

위의 방식과 같이 Context.Provider를 통해 요소를 감쌀 수 있지만, React 19버전부터는 .Provider를 사용하지 않고 다음과 같이 작성하는것도 가능하다.

...
...
const cartValue: CartContextType = {
    items: shoppingCartState.items,
    addItemToCart: handleAddItemToCart,
    updateCartItemQuantity: handleUpdateCartItemQuantity
};
...
...
<CartContext value={cartValue}>
  <Header />
  <Shop>
    {DUMMY_PRODUCTS.map((product) => (
      <li key={product.id}>
        <Product {...product} />
      </li>
    ))}
  </Shop>
</CartContext>

context참조하기

Context를 통해 감싸진 컴포넌트의 하위 컴포넌트는 prop drilling을 하지 않고도 해당 컨텍스트의 데이터를 사용하는것이 가능하다.
컨텍스트를 참조하기 위한 전통적인 방법으로는 useContext가 존재하며, React 19버전부터 정식출시된 use를 사용한 방식도 가능하다.

useContext

const value = useContext(SomeContext)와 같은 방식으로 사용하며, value에는 SomeContext의 value값이 전달된다.

use

const value = use(resource);와 같은 방식으로 사용되며, value에는 resource의 값이 전달된다.

useContext와 use의 차이

둘 다 매개변수로 Context값을 전달할 수 있는 공통점이 있지만, use는 특별히 Promise데이터도 참조하는것이 가능하다.
또한 useContext는 hook의 규칙에 따라 if나 for같은 조건, 반복문 내부에서 호출할 수 없지만, use는 가능하다.

useContext를 사용할때 주의점

사람들이 context와 useContext를 사용하는 것을 상태관리를 위한 React API로 오해하는 경우가 있는데, Context는 상태를 주입하는 API이다.
상태 관리 라이브러리가 되기 위해서는 다음 두 가지 조건을 만족해야 한다.

  1. 어떠한 상태를 기반으로 다른 상태를 만들어 낼 수 있어야 한다.
  2. 필요에 따라 이러한 상태 변화를 최적화할 수 있어야 한다.

하지만 컨텍스트는 위의 두 조건을 모두 만족하지 못한다.
컨텍스트는 단순히 props의 값을 하위로 전달하는 역할만 한다.
또한 특정 Context를 사용하는 경우, 해당 Context를 사용하는 모든 자식들은 리렌더링 되기 때문에 렌더링 최적화를 위해서는 추가적인 조작이 필요하다.

useReducer와 Context API

위에서 알아본 Context API를 통해 Context의 Provider에 대한 코드를 작성할 수 있다.

import { ReactNode, useState } from "react";
import { DUMMY_PRODUCTS } from "../dummy-products";
import { CartContext, CartContextType } from "./CartContext";

interface CartContextProviderProps {
  children: ReactNode;
}
export const CartContextProvider = ({ children }: CartContextProviderProps) => {
  const [shoppingCart, setShoppingCart] = useState<ShoppingCartType>({
    items: []
  });

  function handleAddItemToCart(id: string) {
    setShoppingCart((prevShoppingCart) => {
      const updatedItems = [...prevShoppingCart.items];

      const existingCartItemIndex = updatedItems.findIndex((cartItem) => cartItem.id === id);
      const existingCartItem = updatedItems[existingCartItemIndex];

      if (existingCartItem) {
        const updatedItem = {
          ...existingCartItem,
          quantity: existingCartItem.quantity + 1
        };
        updatedItems[existingCartItemIndex] = updatedItem;
      } else {
        const product = DUMMY_PRODUCTS.find((product) => product.id === id);
        if (product) {
          updatedItems.push({
            id: id,
            name: product.title,
            price: product.price,
            quantity: 1
          });
        }
      }

      return {
        items: updatedItems
      };
    });
  }

  function handleUpdateCartItemQuantity(productId: string, amount: number) {
    setShoppingCart((prevShoppingCart) => {
      const updatedItems = [...prevShoppingCart.items];
      const updatedItemIndex = updatedItems.findIndex((item) => item.id === productId);

      const updatedItem = {
        ...updatedItems[updatedItemIndex]
      };

      updatedItem.quantity += amount;

      if (updatedItem.quantity <= 0) {
        updatedItems.splice(updatedItemIndex, 1);
      } else {
        updatedItems[updatedItemIndex] = updatedItem;
      }

      return {
        items: updatedItems
      };
    });
  }

  const cartValue: CartContextType = {
    items: shoppingCart.items,
    addItemToCart: handleAddItemToCart,
    updateCartItemQuantity: handleUpdateCartItemQuantity
  };

  return <CartContext value={cartValue}>{children}</CartContext>;
};

이때 CartContextProvider컴포넌트 내부의 state업데이트 로직을 보면 컴포넌트 내부에서 너무 많은 코드들이 동작하는 것을 볼 수 있다.
이때 useReducer를 사용하면 복잡한 state를 더 용이하게 관리할 수 있다.

useReducer

useReducer는 컴포넌트에 reducer를 추가하는 React Hook으로 다음과 같은 형태를 사용한다.

const [state, dispatch] = useReducer(reducer, initialArg, init?)
  • reducer는 state가 어떻게 업데이트 되는지 지정하는 리듀서 함수로, state와 action을 인수로 받으며 반드시 state를 반환해야한다.
  • initialArg는 초기 state가 계산되는 값이다.
  • init은 초기 state를 반환하는 초기화 함수로, 할당되지 않을 경우 state는 initialArg로 설정된다. 할당되었다면 초기 state는 init(initialArg)의 반환값이다.

useReducer는 다음과 같은 2개의 엘리먼트로 구성된 배열을 반환한다.

  1. 현재 state
  2. dispatch함수

dispatch함수는 state를 새로운 값으로 업데이트하고 리렌더링을 일으킨다.
dispatch함수의 인수로는 action이 사용된다.

정리하자면 state와 dispatch를 통해 전달된 action을 제공받아 호출된 reducer의 반환값을 통해 다음 state값을 설정한다.

useReducer 사용하기

import { ReactNode, useReducer } from "react";
import { DUMMY_PRODUCTS } from "../dummy-products";
import { CartContext, CartContextType } from "./CartContext";

interface CartContextProviderProps {
  children: ReactNode;
}

enum ShoppingCartActionType {
  ADD_ITEM = "ADD-ITEM",
  UPDATE_ITEM = "UPDATE-ITEM"
}

type ShoppingCartReducerAction =
  | { type: ShoppingCartActionType.ADD_ITEM; payload: { id: string } }
  | { type: ShoppingCartActionType.UPDATE_ITEM; payload: { productId: string; amount: number } };

function shoppingCartReducer(state: ShoppingCartType, action: ShoppingCartReducerAction) {
  const { type, payload } = action;

  if (type === "ADD-ITEM") {
    const updatedItems = [...state.items];

    const existingCartItemIndex = updatedItems.findIndex((cartItem) => cartItem.id === payload.id);
    const existingCartItem = updatedItems[existingCartItemIndex];

    if (existingCartItem) {
      const updatedItem = {
        ...existingCartItem,
        quantity: existingCartItem.quantity + 1
      };
      updatedItems[existingCartItemIndex] = updatedItem;
    } else {
      const product = DUMMY_PRODUCTS.find((product) => product.id === payload.id);
      if (product) {
        updatedItems.push({
          id: payload.id,
          name: product.title,
          price: product.price,
          quantity: 1
        });
      }
    }

    return {
      ...state,
      items: updatedItems
    };
  }

  if (type === "UPDATE-ITEM") {
    const updatedItems = [...state.items];
    const updatedItemIndex = updatedItems.findIndex((item) => item.id === payload.productId);

    const updatedItem = {
      ...updatedItems[updatedItemIndex]
    };

    updatedItem.quantity += payload.amount;

    if (updatedItem.quantity <= 0) {
      updatedItems.splice(updatedItemIndex, 1);
    } else {
      updatedItems[updatedItemIndex] = updatedItem;
    }

    return {
      ...state,
      items: updatedItems
    };
  }

  return state;
}

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

  function handleAddItemToCart(id: string) {
    shoppingCartDispatch({ type: ShoppingCartActionType.ADD_ITEM, payload: { id } });
  }

  function handleUpdateCartItemQuantity(productId: string, amount: number) {
    shoppingCartDispatch({
      type: ShoppingCartActionType.UPDATE_ITEM,
      payload: { productId, amount }
    });
  }

  const cartValue: CartContextType = {
    items: shoppingCartState.items,
    addItemToCart: handleAddItemToCart,
    updateCartItemQuantity: handleUpdateCartItemQuantity
  };

  return <CartContext value={cartValue}>{children}</CartContext>;
};

이전의 CartContextProvider를 useReducer를 통해 변경한 코드이다.
자세히 살펴보면 다음과 같다.

enum ShoppingCartActionType {
  ADD_ITEM = "ADD-ITEM",
  UPDATE_ITEM = "UPDATE-ITEM"
}

type ShoppingCartReducerAction =
  | { type: ShoppingCartActionType.ADD_ITEM; payload: { id: string } }
  | { type: ShoppingCartActionType.UPDATE_ITEM; payload: { productId: string; amount: number } };

function shoppingCartReducer(state: ShoppingCartType, action: ShoppingCartReducerAction) {
  const { type, payload } = action;

  if (type === "ADD-ITEM") {
    const updatedItems = [...state.items];

    const existingCartItemIndex = updatedItems.findIndex((cartItem) => cartItem.id === payload.id);
    const existingCartItem = updatedItems[existingCartItemIndex];

    if (existingCartItem) {
      const updatedItem = {
        ...existingCartItem,
        quantity: existingCartItem.quantity + 1
      };
      updatedItems[existingCartItemIndex] = updatedItem;
    } else {
      const product = DUMMY_PRODUCTS.find((product) => product.id === payload.id);
      if (product) {
        updatedItems.push({
          id: payload.id,
          name: product.title,
          price: product.price,
          quantity: 1
        });
      }
    }

    return {
      ...state,
      items: updatedItems
    };
  }

  if (type === "UPDATE-ITEM") {
    const updatedItems = [...state.items];
    const updatedItemIndex = updatedItems.findIndex((item) => item.id === payload.productId);

    const updatedItem = {
      ...updatedItems[updatedItemIndex]
    };

    updatedItem.quantity += payload.amount;

    if (updatedItem.quantity <= 0) {
      updatedItems.splice(updatedItemIndex, 1);
    } else {
      updatedItems[updatedItemIndex] = updatedItem;
    }

    return {
      ...state,
      items: updatedItems
    };
  }

  return state;
}

위의 코드는 reducer함수와 해당 함수에서 사용될 상수, 타입을 정의한 코드이다.
위의 reducer함수는

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

코드를 통해 state와 dispatch와 연결된다.
reducer함수는 인수로 state값과 action을 가진다.
state는 연결된 상태를 의미하며, action은 ShoppingCartReducerAction타입을 가지며, type은 어떤 동작을 할지에 대한 값이고, payload는 dispatch함수를 통해 전달받은 인수를 가지고있다.

function handleAddItemToCart(id: string) {
  shoppingCartDispatch({ type: ShoppingCartActionType.ADD_ITEM, payload: { id } });
}

function handleUpdateCartItemQuantity(productId: string, amount: number) {
  shoppingCartDispatch({
    type: ShoppingCartActionType.UPDATE_ITEM,
    payload: { productId, amount }
  });
}

위의 코드를 살펴보면 shoppingCartDispatch를 호출한 뒤 인수로 type과 payload를 전달하는것을 볼 수 있다.

이렇게 전달받은 type을 통해 shoppingCartReducer내부에서는 if문을 사용하여 어떤 작업을할지 선택할 수 있고, 이때 payload의 값을 사용하게된다.

shoppingCartReducer함수는 항상 state를 반환하며, 이렇게 반환된 state값을 통해 shoppingCartState가 업데이트된다.

useReducer hook을 사용한다고 코드가 드라마틱하게 줄어들지는 않지만, 컴포넌트 내부에 작성하였던 상태관리 코드를 컴포넌트 함수 외부에서 작성한다는 이점이 있다.

0개의 댓글