[Project] Cmarket Redux

유슬기·2023년 2월 27일
0

프론트엔드

목록 보기
51/64
post-thumbnail

목표: React useState로 상태를 관리했던 지난 Cmarket Hooks를 Redux를 사용하여 구현

요구사항 (test case)

  1. Shopping Cart Actions
    ✓ addToCart는 ADD_TO_CART 액션을 생성해야 합니다
    ✓ removeFromCart는 REMOVE_FROM_CART 액션을 생성해야 합니다
    ✓ setQuantity는 SET_QUANTITY 액션을 생성해야 합니다
  2. Item Reducer
    ✓ ADD_TO_CART 액션에 따라 cartItems 상태가 변해야 합니다
    ✓ REMOVE_FROM_CART 액션에 따라 cartItems 상태가 변해야 합니다
    ✓ SET_QUANTITY 액션에 따라 cartItems 상태가 변해야 합니다
    ✓ 리듀서는 다른 상태의 값을 보존해야 합니다
  3. Shopping Pages
    ✓ ShoppingCart에 cartItems가 렌더되어야합니다
    ✓ ADD_TO_CART 액션에 따라 ShoppingCart가 렌더되어야 합니다
    ✓ REMOVE_FROM_CART 액션에 따라 ShoppingCart가 렌더되어야 합니다
    ✓ SET_QUANTITY 액션에 따라 OrderSummary가 렌더되어야 합니다
    ✓ Checkbox의 상태에 따라 OrderSummary가 렌더되어야 합니다

풀이

Action → Dispatch → Reducer → Store(New State) 순으로 데이터가 흐르기 때문에 그 순서대로 코드를 작성해보자.

Action

// actions/index.js

// action types를 변수로 선언 (재사용 용이, 오타 방지)
export const ADD_TO_CART = "ADD_TO_CART";
export const REMOVE_FROM_CART = "REMOVE_FROM_CART";
export const SET_QUANTITY = "SET_QUANTITY";
export const NOTIFY = "NOTIFY";
export const ENQUEUE_NOTIFICATION = "ENQUEUE_NOTIFICATION";
export const DEQUEUE_NOTIFICATION = "DEQUEUE_NOTIFICATION";

// 액션 생성자 함수를 type 별로 만들어준다
export const addToCart = (itemId) => {
  return {
    type: ADD_TO_CART,
    payload: {
      quantity: 1,
      itemId
    }
  }
}

export const removeFromCart = (itemId) => {
  return {
    type: REMOVE_FROM_CART,
    payload: {
      itemId
    }
  }
}

export const setQuantity = (itemId, quantity) => {
  return {
    type: SET_QUANTITY,
    payload: {
      itemId,
      quantity
    }
  }
}

export const notify = (message, dismissTime = 5000) => dispatch => {
  const uuid = Math.random()
  dispatch(enqueueNotification(message, dismissTime, uuid))
  setTimeout(() => {
    dispatch(dequeueNotification())
  }, dismissTime)
}

export const enqueueNotification = (message, dismissTime, uuid) => {
  return {
    type: ENQUEUE_NOTIFICATION,
    payload: {
      message,
      dismissTime,
      uuid
    }
  }
}

export const dequeueNotification = () => {
  return {
    type: DEQUEUE_NOTIFICATION
  }
}

action types 문자열을 변수로 선언하여 사용한 이유

  • 오타 방지
  • 자동완성 기능을 통해 코드 생산성을 높일 수 있음
  • 코드 재사용 용이

Action 객체를 만들 때, type은 필수로 지정을 해 주어야 한다.
type이 해당 Action 객체가 어떤 동작을 하는지 명시해주는 역할을 하기 때문.
여기에 필요에 따라 payload를 작성해 구체적인 값을 전달.

Dispatch

UI에서 상호작용(상태가 변경되어야 하는 이벤트 발생)시, 변경될 상태에 대한 정보가 담긴 Action 객체가 생성 → 이 Action 객체를 Dispatch 함수의 인자로 전달 → Dispatch 함수는 Action 객체를 전달받아 Reducer 함수로 전달

// pages/ItemListContainer.js

import React from 'react';
import { addToCart, notify } from '../actions/index';
import { useSelector, useDispatch } from 'react-redux';
import Item from '../components/Item';

function ItemListContainer() {
  // useSelector 함수로 state 받아오기
  const state = useSelector(state => state.itemReducer);
  const { items, cartItems } = state; // 구조분해 할당
  const dispatch = useDispatch();

  const handleClick = (item) => {
    // cartItems에 해당 item의 Id가 포함되어 있지 않은 경우에 실행
    if (!cartItems.map((el) => el.itemId).includes(item.id)) {
      // dispatch를 통해 아이템 추가에 대한 액션 전달
      dispatch(notify(`장바구니에 ${item.name}이(가) 추가되었습니다.`))
      dispatch(addToCart(item.id)) // addToCart 함수에 item의 id를 전달
    }
    // cartItems에 해당 item의 Id가 포함되어 있는 경우에 실행
    else {
      dispatch(notify('이미 추가된 상품입니다.'))
    }
  }

  return (
    <div id="item-list-container">
      <div id="item-list-body">
        <div id="item-list-title">쓸모없는 선물 모음</div>
        {/* 받아온 state 중 items 배열의 모든 요소를 map을 통해 렌더링 */}
        {items.map((item, idx) => <Item item={item} key={idx} handleClick={() => {
          handleClick(item)
        }} />)}
      </div>
    </div>
  );
}

export default ItemListContainer;

handleClick은 Item 컴포넌트에서 사용하므로 props로 전달

// components/Item.js

import React from 'react'

export default function Item({ item, handleClick }) {

  return (
    <div key={item.id} className="item">
      <img className="item-img" src={item.img} alt={item.name}></img>
      <span className="item-name" data-testid={item.name}>{item.name}</span>
      <span className="item-price">{item.price}</span>
      <button className="item-button" onClick={(e) => handleClick(e, item.id)}>장바구니 담기</button>
    </div>
  )
}

Item 컴포넌트의 button 클릭 이벤트에 props로 받아온 handleClick 추가해주기

// pages/ShoppingCart.js

import React, { useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { removeFromCart, setQuantity } from '../actions';
import CartItem from '../components/CartItem'
import OrderSummary from '../components/OrderSummary'

export default function ShoppingCart() {

  const state = useSelector(state => state.itemReducer);
  const { cartItems, items } = state
  const dispatch = useDispatch();
  const [checkedItems, setCheckedItems] = useState(cartItems.map((el) => el.itemId))

  // 상품 선택
  const handleCheckChange = (checked, id) => {
    if (checked) {
      // 체크박스 엘리먼트의 checked 속성과 연결
      setCheckedItems([...checkedItems, id]);
    }
    else {
      setCheckedItems(checkedItems.filter((el) => el !== id));
    }
  };

  // 전체 선택
  const handleAllCheck = (checked) => {
    if (checked) {
      setCheckedItems(cartItems.map((el) => el.itemId))
    }
    else {
      setCheckedItems([]);
    }
  };

  const handleQuantityChange = (quantity, itemId) => {
    // dispatch를 통해 setQuantity에 대한 액션 전달
    dispatch(setQuantity(itemId, quantity));
  }

  const handleDelete = (itemId) => {
    // dispatch를 통해 removeFromCart에 대한 액션 전달
    setCheckedItems(checkedItems.filter((el) => el !== itemId))
    dispatch(removeFromCart(itemId))
  }

  const getTotal = () => {
    let cartIdArr = cartItems.map((el) => el.itemId)
    let total = {
      price: 0,
      quantity: 0,
    }
    for (let i = 0; i < cartIdArr.length; i++) {
      if (checkedItems.indexOf(cartIdArr[i]) > -1) {
        let quantity = cartItems[i].quantity
        let price = items.filter((el) => el.id === cartItems[i].itemId)[0].price

        total.price = total.price + quantity * price
        total.quantity = total.quantity + quantity
      }
    }
    return total
  }

  const renderItems = items.filter((el) => cartItems.map((el) => el.itemId).indexOf(el.id) > -1)
  const total = getTotal()

  return (
    <div id="item-list-container">
      <div id="item-list-body">
        <div id="item-list-title">장바구니</div>
        <span id="shopping-cart-select-all">
          <input
            type="checkbox"
            checked={
              checkedItems.length === cartItems.length ? true : false
            }
            onChange={(e) => handleAllCheck(e.target.checked)} >
          </input>
          <label >전체선택</label>
        </span>
        <div id="shopping-cart-container">
          {!cartItems.length ? (
            <div id="item-list-text">
              장바구니에 아이템이 없습니다.
            </div>
          ) : (
              <div id="cart-item-list">
                {renderItems.map((item, idx) => {
                  const quantity = cartItems.filter(el => el.itemId === item.id)[0].quantity
                  return <CartItem
                    key={idx}
                    handleCheckChange={handleCheckChange}
                    handleQuantityChange={handleQuantityChange}
                    handleDelete={handleDelete}
                    item={item}
                    checkedItems={checkedItems}
                    quantity={quantity}
                  />
                })}
              </div>
            )}
          <OrderSummary total={total.price} totalQty={total.quantity} />
        </div>
      </div >
    </div>
  )
}

Reducer

Reducer는 Dispatch에게 전달받은 Action 객체의 type 값에 따라서 상태를 변경시킨다.

// reducers/index.js

import { combineReducers } from 'redux';
import itemReducer from './itemReducer';
import notificationReducer from './notificationReducer';

// combineReducers를 사용하여 여러 개의 리듀서를 하나로 합쳐주기
const rootReducer = combineReducers({
  itemReducer,
  notificationReducer
});

export default rootReducer;

combineReducers를 사용하면 여러 개의 리듀서를 하나로 합쳐줄 수 있다.

import { REMOVE_FROM_CART, ADD_TO_CART, SET_QUANTITY } from "../actions/index";
import { initialState } from "./initialState";

const itemReducer = (state = initialState, action) => {

  switch (action.type) {
    case ADD_TO_CART:
      // 원본 state를 건들지 않고 복사한 뒤 변경하고 싶은 부분만 변경하여 새로운 상태를 리렌더링
      return {...state, cartItems: [...state.cartItems, action.payload]};
      // 아래와 동일하게 작동한다
      // return Object.assign({}, state, {
      //   cartItems: [...state.cartItems, action.payload]
      // })
      
    case REMOVE_FROM_CART:
      // 삭제할 상품을 제외한 나머지 상품을 필터링
      let filterItems = state.cartItems.filter(el => el.itemId !== action.payload.itemId);
      // Add to cart와 동일하게 원본 state 복사해서 사용
      return {...state, cartItems: filterItems}

    case SET_QUANTITY:
      let idx = state.cartItems.findIndex(el => el.itemId === action.payload.itemId)
      let newArr = [...state.cartItems];
      newArr[idx] = action.payload;
      return {...state, cartItems: newArr}
      
    default:
      return state;
  }
}

export default itemReducer;

Reducer가 리턴하는 값이 새로운 state가 되며, Reducer 함수는 초기 상태와 액션 객체를 인자로 받는다.

Reducer를 통해 새로운 state를 리턴할 때, 기존의 state는 건들지 않아야 하며, (불변성 유지) state가 참조형 데이터일 경우 주소값이 바뀌어야 state가 업데이트된 것을 Redux에서 기록할 수 있기 때문에 보통 spread operator나 Object.assign 등을 통해 copy 후 변경이 필요한 부분만 변경해준다.

// reducers/notificationReducer.js

import { ENQUEUE_NOTIFICATION, DEQUEUE_NOTIFICATION } from "../actions/index";

const notificationReducer = (state = {notifications:[]}, action) => {

  switch (action.type) {
    case ENQUEUE_NOTIFICATION:
      return Object.assign({}, state, {
        notifications: [...state.notifications, action.payload]
      })
      
    case DEQUEUE_NOTIFICATION:
      return Object.assign({}, state, {
        notifications: state.notifications.slice(1)
      })
      
    default:
      return state;
  }
}

export default notificationReducer;

Store

// store/store.js

import { compose, createStore, applyMiddleware } from "redux";
import rootReducer from '../reducers/index';
import thunk from "redux-thunk";

// 크롬 익스텐션 중 리덕스 상태를 볼 수 있는 프로그램을 사용하게끔 하는 코드
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({})
  : compose;

// Reducer를 연결해 store 생성 및 store 변수에 할당
const store = createStore(rootReducer, composeEnhancers(applyMiddleware(thunk)));

export default store;

createStore 메서드를 사용해 Reducer를 연결해 Store를 생성할 수 있음.
생성한 Store는 최상위 컴포넌트에서 연결해준다.

// index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import store from './store/store';
import { Provider } from 'react-redux';

ReactDOM.render(
  // App 컴포넌트를 Provider로 감싸준 후 props로 변수 store를 전달
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

App 컴포넌트를 Procider로 감싸준 후, props로 변수 store를 전달한다.

profile
아무것도 모르는 코린이

0개의 댓글