TIL 15) React-Redux로 상태 관리하기

Hover·2023년 4월 26일
0

TIL

목록 보기
17/27

지난번엔 useState를 통해 상태를 관리했었다.

하지만, 컴포넌트 간 props로만 상태를 전달하고 관리하려니, props drilling 이 일어나게 되었다.

이럴 때 사용하는 것이 Redux 이다.

Redux 란?

Redux는 자바스크립트에서 상태를 관리하기 위한 도구다.

Redux는 단 하나의 store에서 전체 app의 상태를 관리하는 것이다.

store는 읽기 전용이며, action이라 불리는 객체를 통해 상태를 변경 할 수 있다.

Redux에서의 Reducer

Reducer는 현재 상태와 액션을 입력으로 받아서 새로운 상태를 반환하는 순수 함수다.

순수 함수이므로, 예측 가능한 결과를 도출한다.

const itemReducer = (state = initialState, action) => {
  // 초기값,action
  switch (action.type) {
    case ADD_TO_CART:
      //TODO..
    case REMOVE_FROM_CART:
      //TODO..
    case SET_QUANTITY:
		// TODO..
    default:
      return state;
  }
};

action에 따라 state를 업데이트 시킨다.

리듀서는 순수 함수이고 내부 case는 객체의 불변성을 지키므로 App 상태의 추론을 더욱 쉽게 만들어준다.

React-Redux

react-redux는 react 안에서 보다 redux를 편하게 사용할 수 있게 해주는 라이브러리다.

react-redux의 사용 방법은 다음과 같다. (Counter)

1. React-Redux 설치하기

npm install react-redux // npm install redux

2. 액션 생성자 만들기

export const incrementCounter = () => ({
  type: 'INCREMENT_COUNTER'
});
export const decrementCounter = () => ({
  type: 'DECREMENT_COUNTER'
});

3. 액션 생성자를 통한 리듀서 만들기.

const initialState = {
  count: 0
};
const counterReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'INCREMENT_COUNTER':
      return {
        ...state,
        count: state.count + 1
      };
    case 'DECREMENT_COUNTER':
      return {
        ...state,
        count: state.count - 1
      };
    default:
      return state;
  }
};
export default counterReducer;

4. Reducer를 기반으로 한 store 만들기

import { createStore } from 'redux';
import counterReducer from './reducers';
const store = createStore(counterReducer);

5. App의 최상위 컴포넌트를 Provider로 감싸주기

import { Provider } from 'react-redux';
import store from './store';
function App() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}

6.위에서 만든 것을 사용하는 Counter컴포넌트 만들기

import { useSelector, useDispatch } from 'react-redux';
import { incrementCounter, decrementCounter } from './actions';
function Counter() {
  const count = useSelector(state => state.count);// useSelector를 이용해 store에서 state를 가져온다. 
  //state는 store에서 count:0이 default임.
  const dispatch = useDispatch();
  const increment = () => {
    dispatch(incrementCounter());
  };
  const decrement = () => {
    dispatch(decrementCounter());
  };
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
}

CMARKET에서의 데이터 흐름

각 컴포넌트들은 Reducer에서 state를 받아와 하위 컴포넌트에 전달해주고

컴포넌트에서 상태 변경이 필요할 경우 dispatch를 통해 Reducer로 넘겨준다.

Store

//store.js
import {legacy_createStore as createStore} from "redux"
import rootReducer from '../reducers/index"

const store = createStore(rootReducer);

store.js에서 store를 선언 해준다.

그 후, rootReducer를 import 해와서 Store에 넣어준다.

이러면, rootReducer에 있는 모든 reducer가 store에 저장이 된다.

Reducer

// reducers/index.js
import { combineReducers } from "redux";
import itemReducer from "./itemReducer";
import notificationReducer from "./notificationReducer";

const rootReducer = combineReducers({
  itemReducer,
  notificationReducer,
});

export default rootReducer;

reducer가 선언되는 곳이다.

현재 CMARKET에는 두 개의 reducer가 사용되는데

여러 개의 reducer를 사용하려면 redux에 있는 combineReducers를 사용한다.

initialState

// reducers/initialState.js
export const initialState = {
  items: [
    {
      id: 1,
      name: "노른자 분리기",
      img: "../images/egg.png",
      price: 9900,
    },
    {
      id: 2,
      name: "2020년 달력",
      img: "../images/2020.jpg",
      price: 12000,
    },
    ......
    
    cartItems: [
    {
      itemId: 1,
      quantity: 1,
    },
    {
      itemId: 5,
      quantity: 7,
    },  
    {
      itemId: 2,
      quantity: 3,
    },
  ],

Reducer의 초기값을 담당하는 객체 배열이다.

상품 item과 카트에 담긴 상품의 배열이 담겨져있다.

itemReducers.js

// reducers/itemReducer.js

import { REMOVE_FROM_CART, ADD_TO_CART, SET_QUANTITY } from "../actions/index";//action의 type과 payload를 가져오는 함수
import { initialState } from "./initialState";//초기값

const itemReducer = (state = initialState, action) => {
  switch (action.type) {
    case ADD_TO_CART:
      //TODO : cartItems에 아이템 추가
      // 객체의 불변성을 위해 ...state로 기존 배열을 복사한 후 추가한다.
      //state(initialState)의 cartItems를 변경시키는 것.
      return {
        ...state,
        cartItems: [...state.cartItems, action.payload],
      };
    case REMOVE_FROM_CART:
      //TODO : cartItem에서 해당 상품 제거
      // action.payload << 여기에 itemId 들어가 있음.
      const removearr = state.cartItems.filter((it) => {
        return it.itemId !== action.payload.itemId;
      });
      return {
        ...state,
        cartItems: removearr,
      };
    case SET_QUANTITY:
      // TODO : 상품의 수량 변경
      let idx = state.cartItems.findIndex(
        (el) => el.itemId === action.payload.itemId
      ); // cartItem 배열에서 payload로 온 itemid랑 같은 인덱스
      let updatedcart = [...state.cartItems];
      // cartItems 배열 복사
      const sameItem = updatedcart[idx];
      // 복사한 배열에서 상품 추출

      let updated = {
        ...sameItem,
        quantity: action.payload.quantity,
      };
      updatedcart[idx] = updated;
      return {
        ...state,
        cartItems: updatedcart,
      };
    default:
      return state;
  }
};

export default itemReducer;

초기값을 state로 사용하는 itemReducer다.

각 action.type마다 실행하는 로직이 있으며, initialState의 상태를 변경시킨다.

action

//actions/index.js
export const ADD_TO_CART = "ADD_TO_CART";
export const REMOVE_FROM_CART = "REMOVE_FROM_CART";
export const SET_QUANTITY = "SET_QUANTITY";


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: {
      quantity,
      itemId,
    },
  };
};

각 함수마다 전달하는 action type,payload가 담겨져있다.

itemListContainer

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

function ItemListContainer() {
  const state = useSelector((state) => state.itemReducer);
  // rootReducer에서 가져온 reducer 중 하나인 itemReudcer
  // 해당 reducer에서 useSelector를 통하여 state를 가져온다.
  const { items, cartItems } = state;
  // state(initialState)는 총 2개의 배열이 있으며 이것을 구조 분해 할당을 통해 변수로 선언해준다.
  const dispatch = useDispatch();
// action을 전달하기 위한 useDispatch 선언
  const handleClick = (item) => {
    if (!cartItems.map((el) => el.itemId).includes(item.id)) {
      dispatch(addToCart(item.id));
      dispatch(notify(`장바구니에 ${item.name}이(가) 추가되었습니다.`));
    } else {
      dispatch(notify("이미 추가된 상품입니다."));
    }
  };

  return (
    <div id="item-list-container">
      <div id="item-list-body">
        <div id="item-list-title">쓸모없는 선물 모음</div>
        {items.map((item, idx) => (
          <Item
            item={item}
            key={idx}
            handleClick={() => {
              handleClick(item);
            }}
          />
        ))}
      </div>
    </div>
  );
}

export default ItemListContainer;

상품을 보여주는 메인 화면이다.

상품 추가를 해주는 함수 handleClick이 선언되어있다.

ShoppingCart

// 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) {
      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) => {
    // 선택된 상품의 id와 수량을 넘겨준다.
    dispatch(setQuantity(itemId, quantity));
  };

  // 장바구니 상품 제거 함수
  const handleDelete = (itemId) => {
    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>
  );
}

장바구니를 보여주는 페이지다.

장바구니에서 상품 삭제 및 수량 변경이 가능하다.

그 외 컴포넌트들

1. item

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>
  )
}

ItemListContainer에 있는 컴포넌트로 장바구니 담기의 기능을 담당하는 함수에 params를 전달해준다.

2. cartItem

import React from 'react'

export default function CartItem({
  item,
  checkedItems,
  handleCheckChange,
  handleQuantityChange,
  handleDelete,
  quantity
}) {
  return (
    <li className="cart-item-body">
      <input
        type="checkbox"
        className="cart-item-checkbox"
        onChange={(e) => {
          handleCheckChange(e.target.checked, item.id)
        }}
        checked={checkedItems.includes(item.id) ? true : false} >
      </input>
      <div className="cart-item-thumbnail">
        <img src={item.img} alt={item.name} />
      </div>
      <div className="cart-item-info">
        <div className="cart-item-title" data-testid={`cart-${item.name}`}>{item.name}</div>
        <div className="cart-item-price">{item.price}</div>
      </div>
      <input
        type="number"
        min={1}
        className="cart-item-quantity"
        value={quantity.toString().replace(/(^0+)/, "")}
        // input에서 한 자리 숫자면 앞에 0이 붙는걸 제거해주는 표현식
        onChange={(e) => {
          handleQuantityChange(Number(e.target.value), item.id)
        }}>
      </input>
      <button className="cart-item-delete" onClick={() => { handleDelete(item.id) }}>삭제</button>
    </li >
  )
}

장바구니 페이지인 ShoppingCart에 있는 컴포넌트.

수량 변경 및 장바구니에서 상품 삭제를 담당하는 함수에 params를 넘겨준다.

profile
프론트엔드 개발자 지망생입니다

0개의 댓글