[React 완벽 가이드] Section 9 & Section 10: 실습 & Reducer

gonn-i·2024년 6월 11일

React 완벽 가이드

목록 보기
7/18
post-thumbnail

본 포스트는 Udemy 리액트 완벽가이드 2024 를 듣고 정리한 내용입니다.

목차 🌳
Section 9: 프로젝트 관리 앱 만들기
Section 10:
1️⃣ prop Drilling
2️⃣ Component 합성을 통한 문제 해결
3️⃣ useContext 사용 ✨
4️⃣ Reducer 이란? ✨

Section 9

프로젝트 관리 앱 만들기

미니 프로젝트 정리 내용 👀


Section 10

Prop Drilling 📥

리액트 는 컴포넌트 Tree 로 구성되어 있음

특정 상태값 변경 State 을 감시해야 하는, 다른 위치의 컴포넌트 가 있는 경우!
(Tree에서 최상위 컴포넌트를 기점으로 갈라지는 경우 등)
prop을 통해 값의 공유와 업데이트가 이루어진다

위의 트리 형태에서 볼 수 있듯이, 여러 층의 컴포넌트를 거쳐 공유하는 상태값 state 을 넘겨주다보면
부모 -> 자식(부모) -> 자식(부모) -> 자식 과 같이 prop 이 연쇄적으로 인자를 통해 타고 내려가는 Prop Drilling 이 발생한다.
(중간 컴포넌트에서 그 값이 필요하지 않더라도, 자식 컴포넌트가 필요하면 끊기지 않게 일단 전달해줘야 함)

🥸 이게 왜 문제인가요?
1️⃣ 누락의 가능성
-타고 내려가는 구조 이기 때문에, 필요한 prop 이 타켓 컴포넌트에 전달되지 않은 경우, 어떤 중간 컴포넌트에서 누락된건지 타고 올라가면서 확인해야 함.
2️⃣ 중간 컴포넌트에서 불필요하게 prop을 전달
-간결하지 못하고, 불필요한 코드의 중복 발생
3️⃣ 이름을 통해 prop 을 추적하기 어려움
-prop 이름이 계층을 타고 내려가면서 변경되면, 그 값을 추적하고 업데이트 하기 어려움

Prop Drilling 해결방법

Component 합성

컴포넌트 합성을 통해,prop을 다시 전달하는 부분을 부모쪽으로 올려서 구성하는 방법이다.
이로써, 컴포넌트의 중첩을 한 층 삭제하여 프롭 드릴링 일부 제거 가능!

컴포넌트 합성 전

App -> Shop -> Product로 handleAddItemToCart 함수 전달

App 컴포넌트

function App() {
	... 생략
  return (
    <>
      <Header ... 생략 .../>
      <Shop onAddItemToCart={handleAddItemToCart} />
    </>
  );
}

Shop 컴포넌트

export default function Shop({ onAddItemToCart }) {
  return (
    <section id="shop">
      <ul id="products">
        {DUMMY_PRODUCTS.map((product) => (
          <li key={product.id}>
            <Product {...product} onAddToCart={onAddItemToCart} />
          </li>
        ))}
      </ul>
    </section>
  );
}

⬇️⬇️⬇️⬇️⬇️⬇️⬇️✨ 컴포넌트 합성 이후 ✨ ⬇️⬇️⬇️⬇️⬇️⬇️⬇️

컴포넌트 합성 후

App -> Product로 handleAddItemToCart 함수 전달

App 컴포넌트

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

그치만 컴포넌트 합성은 최고의 선택이 아님 🙃

왜나면!
모든 컴포넌트의 층에 사용하기 적합하지 않음
-> 결국 모든 컴포넌트가 App.jsx 에 있는 App component에 들어가고 나머지 컴포넌트는 감싸는 용도로만 사용되기 때문에 App component가 매우 커짐 주의!! ⚠️


Context ✨

Context 를 통해, prop 사용없이 컴포넌트 트리 전역적으로 데이터 제공 가능!

엄밀히 말하자면, 컨텍스트 안에 묶인 컴포넌트간 데이터 공유가 자유로운 것!✨

그래서 어떻게 사용하나요? 🤨

1️⃣ createContext : context 객체 생성
-> 관습이지만, store 폴더에 이름-store.jsx 파일 안에서 생성
-> 단, createContext로 만든 객체는 첫글자를 대문자로 사용하기
(왜냐하면, 리액트 컴포넌트가 들어있는 객체이기 때문)

2️⃣ Provider : 데이터를 공유할 컴포넌트들을 Context 로 묶어주는 역할 (공유 데이터 전달해주는 역할)
-> context이름.Provider wrapper를 통해 값에 접근할 컴포넌트들을 묶어줌
-> ex. <CartContext.Provider> ... 컴포넌트들... </CartContext.Provider>

✨ 이때! <CartContext.Provider> 안에 공유하고자 하는 값을 value 라는 prop으로 설정하면 자식 컴포넌트에서 해당 값에 바로 접근 가능하다!!!
자동완성 기능 쓸 수 있게 해줌! -> 기본값으로 설정되어, 구조분해를 할 수도 있음!✨

그렇기 때문에,

function App () {
 ... 생략 
 
  const ctxValue = {
   items: shoppingCart.items, // state 값
   addItemToCart: handleAddItemToCart,  //함수
 };
 
 return (
   <CartContext.Provider value={ctxValue}>
     ... 생략 
   </CartContext.Provider>
 );
}

다음과 같이 객체로 같이 넘겨주게 되면, context를 받는 자식 컴포넌트에서 구조분해할당하여 사용 가능!

  const { addItemToCart } = useContext(CartContext);
  const { items } = useContext(CartContext);

🚨 단, store안에서 creatContext 도 value 값과 동일하게 선언해주어야 함

import { createContext } from 'react';

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

3️⃣ useContext : 받아온 데이터를 실제 사용하기 위해, Context 불러오는 방법
-> useContext(Context이름) 으로 받아와 사용하면 된다~

여기에서 잠깐! useContext 대신 사용할 수 있는 방법도 있음

Consumer

Provider 과 마찬가지로 Wrapper로써 내부를 감싸긴 하는데, 그 안에 함수를 랜더링해야 한다.

    <CartContext.Consumer> 
 // Consumer 사용하면, 함수를 렌더링 해야하며, provider의 value을 인자로 함
     {(cartCtx) => <div>{cartCtx.items}</div>} 
   </CartContextt.Consumer>

Consumer 실제 사용 코드

import { CartContext } from '../store/shopping-cart-store';

export default function Cart({ onUpdateItemQuantity }) {

 return (
   <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 && (
             <ul id="cart-items">
               {cartCtx.items.map((item) => {
                 const formattedPrice = `$${item.price.toFixed(2)}`;

                 return (
                   <li key={item.id}>
                     <div>
                       <span>{item.name}</span>
                       <span> ({formattedPrice})</span>
                     </div>
                     <div className="cart-item-actions">
                       <button onClick={() => onUpdateItemQuantity(item.id, -1)}>-</button>
                       <span>{item.quantity}</span>
                       <button onClick={() => onUpdateItemQuantity(item.id, 1)}>+</button>
                     </div>
                   </li>
                 );
               })}
             </ul>
           )}
           <p id="cart-total-price">
             Cart Total: <strong>{formattedTotalPrice}</strong>
           </p>
         </div>
       );
     }}
   </CartContext.Consumer>
 );
}

단점
코드가 길고 복잡하고, 읽기 어려움
-> 함수 안에 넣어서 return 해야 하기 때문에 좀 번거로움

혹시 Consumer로 적힌 코드가 있으면, 어떻게 읽고 고치면 좋을지 알아볼 수 있게 하기 위한 용으로 알아두기! (context 기본 사용법으로는 비추)

근데 useContext 를 사용해도 App 컴포넌트가 너무 커진거 아닌가요?

Context 를 분리하여, 이 안에 state 부여 해주면! App 컴포넌트를 간결하게 관리 가능!

분리 전

import { useState } from 'react';

import Header from './components/Header.jsx';
import Shop from './components/Shop.jsx';
import Product from './components/Product.jsx';
import { CartContext } from './store/shopping-cart-store.jsx';
import { DUMMY_PRODUCTS } from './dummy-products.js';

function App() {
  const [shoppingCart, setShoppingCart] = useState({
    items: [],
  });

  function handleAddItemToCart(id) {
    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);
        updatedItems.push({
          id: id,
          name: product.title,
          price: product.price,
          quantity: 1,
        });
      }

      return {
        items: updatedItems,
      };
    });
  }

  function handleUpdateCartItemQuantity(productId, amount) {
    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 ctxValue = {
    items: shoppingCart.items,
    addItemToCart: handleAddItemToCart,
    updateItemQuantity: handleUpdateCartItemQuantity,
  };
  return (
    <CartContext.Provider value={ctxValue}>
      <Header />
      <Shop>
        {DUMMY_PRODUCTS.map((product) => (
          <li key={product.id}>
            <Product {...product} />
          </li>
        ))}
      </Shop>
    </CartContext.Provider>
  );
}

export default App;

⬇️⬇️⬇️⬇️⬇️⬇️✨ context 관련 함수 및 상태 store 파일로 이동 ✨⬇️⬇️⬇️⬇️⬇️⬇️

분리 후

shopping-cart-store.jsx

import { createContext, useState } from 'react';
import { DUMMY_PRODUCTS } from '../dummy-products';

// context 생성
export const CartContext = createContext({
  items: [],
  addItemToCart: () => {},
  updateItemQuantity: () => {},
});

// context provider 
export default function CartContextProvider({ children }) {
  const [shoppingCart, setShoppingCart] = useState({
    items: [],
  });

  function handleAddItemToCart(id) {
    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);
        updatedItems.push({
          id: id,
          name: product.title,
          price: product.price,
          quantity: 1,
        });
      }

      return {
        items: updatedItems,
      };
    });
  }

  function handleUpdateCartItemQuantity(productId, amount) {
    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 ctxValue = {
    items: shoppingCart.items,
    addItemToCart: handleAddItemToCart,
    updateItemQuantity: handleUpdateCartItemQuantity,
  };

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

App 컴포넌트

import Header from './components/Header.jsx';
import Shop from './components/Shop.jsx';
import Product from './components/Product.jsx';
import { DUMMY_PRODUCTS } from './dummy-products.js';
import CartContextProvider from './store/shopping-cart-store.jsx';

function App() {
  return (
    <CartContextProvider> // 아웃소싱한 Provider 컴포넌트 사용하기
      <Header />
      <Shop>
        {DUMMY_PRODUCTS.map((product) => (
          <li key={product.id}>
            <Product {...product} />
          </li>
        ))}
      </Shop>
    </CartContextProvider>
  );
}

export default App;

useReducer ✨

useState와 마찬가지로, 상태 관리 훅이다!
근데 둘중 하나만 쓰면 되지 왜 useReducer도 있냐고?🤨

  • 여러개의 하위값을 포함하는 복잡한 state 관리에 유용하기 때문!
  • 또한, 구조화된 방식으로 상태 관리 가능

그래서 어떻게 사용하나요? 🤨
const [state, dispatch] = useReducer(reducer_리듀서함수, initialState_초기값)
function reducer (state, action) {}

🤝 useReducer 훅을 이용해서 상태값과, dispatch를 선언해주고,
인자로 action이 담긴 함수를 첫번째로 그리고 state의 초기값을 두번째로 준다.
🤝 action들이 담긴 reducer 함수는 인자로 가장 최신의 state값과, action을 받는다.
내부에는 조건문 (if문이나, switch문)을 사용하여 type에 따른 함수를 작성한다.
(꼭 type이라고 할 필요는 없고, identifier 등 자유 -> 대신 사용시 action.type과 같이 action으로 전달됨을 명심할 것)
🤝 state를 바꾸고 싶을때는, dispatch에 인자로 어떤 action을 취할지 보내면 된다.
dispatch({type: 'ADD_ITEM'})
혹시 같이 전달되어야 할 인자가 있다면, dispatch 인자로 같이 넘기면 되는데,
관습으로는 payload 객체 안에 넣어서 전달해준다.

dispatch({
  type: 'ADD_ITEM'
  payload: {
  	id,
	product
	}
})

1️⃣ dispatch : 상태를 업데이트 해주는 함수, action 내용을 전달해줌 (주문서)
Dispatch(Action)

  • dispatch 함수에 Action을 인자로 넣어서, Reducer로 전달해주면 됨.
    dispatch를 호출하면 -> reducer 함수가 돌아감

2️⃣ state : 상태값
-> context이름.Provider wrapper를 통해 값에 접근할 컴포넌트들을 묶어줌

3️⃣ action : state를 어떻게 변경하고 싶은지 요구사항 -> dispatch로 전달됨(주문)

4️⃣ reducer : dispatch 를 확인하여, action대로 state를 변경하는 함수

function reducer (state, action) {}
  • 디스패치에 action이 전달되어 호출되면, 리액트가 reducer를 호출함 (그래서 인자에 action이 있는거)
  • 첫번째 인자인 state는 useReducer로 관리되는 상태의 보장된 최신값!!
    -> 이때, reducer 함수는 useReducer가 선언된 컴포넌트 밖에 선언한다.
    밖에 만드는 이유) 리액트 컴포넌트가 재랜더링될때마다, reducer의 불필요한 재랜더링을 막기 위해

reducer 함수 예시

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

    const existingCartItemIndex = updatedItems.findIndex((cartItem) => cartItem.id === action.payload);
    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 === action.payload);
      updatedItems.push({
        id: action.payload,
        name: product.title,
        price: product.price,
        quantity: 1,
      });
    }

    return {
      items: updatedItems,
    };
  }

  if (action.type === 'UPDATE_ITEM') {
    const updatedItems = [...state.items];
    const updatedItemIndex = updatedItems.findIndex((item) => item.id === action.payload.productId);

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

    updatedItem.quantity += action.payload.amount;

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

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

useReducer과 dispatch 사용 예시

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

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

 function handleUpdateCartItemQuantity(productId, amount) {
   shoppingCartDispatch({
     type: 'UPDATE_ITEM',
     payload: {
       productId,
       amount,
     },
   });
 }
profile
https://gonnn-i.tistory.com/

0개의 댓글