이번 과제에서는 앞서 진행했던 Cmarket Hooks 과제의 상태를 React Hooks가 아닌 Redux로 리팩토링합니다.
과제를 진행하면서 Redux에서 어떻게 상태 변경 로직이 컴포넌트로부터 분리되는지 살펴봅시다.
따라서, Key point는 Redux의 원리와 구조, 즉 설계를 알아가는데 맞춰져있습니다.
다음 Cmarket Shopping App은 Create React App으로 만든 React 애플리케이션에 Redux를 붙인 구조입니다.
이 과정을 통해 Action, Dispatch, Reducer, Store가 어떻게 유기적으로 연결되어 있는지 배우실 수 있습니다.
Action
Action은 말 그대로 어떤 액션을 취할 것인지 정의해 놓은 객체입니다.
{ type: ‘ADD_TO_CART’, payload: request }
보통 위와 같은 모양으로 구성됩니다.
여기서 type은 필수로 지정을 해 주어야 하며,그 외의 것들은 선택적으로 사용할 수 있습니다.
이렇게 모든 변화를 Action을 통해 취하는 것은 우리가 만드는
앱에서 무슨 일이 일어나고 있는지직관적으로 알기 쉽게 하는 역할을 합니다.
Dispatch
Dispatch는 Action을 전달하는 메서드입니다. Dispatch의 전달인자로 Action 객체가 전달됩니다.
그리고 Reducer를 호출해 state의 값을 바꾸는 역할을 합니다.
Store
말 그대로 state가 관리되는 오직 하나뿐인 저장소의 역할을 합니다. Redux 앱의 state가 저장되어 있는 공간이죠.
다음은 createStore 메서드를 활용해 Reducer를 연결하는 방법인데요,
createStore와 더불어 다른 Reducer의 조합을 인자로 넣어서 스토어를 생성할 수 있습니다.
(실제 소스 코드에서는 미들웨어와 Redux devtools 지원을 위해 두 번째 인자에 추가적인 내용이 들어가 있습니다.)
const store = createStore(rootReducer);
store > store.js 파일에서 createStore 메서드를 활용해 rootReducer를 연결해 주고 있습니다.
Reducer
Reducer는 현재의 state와 Action을 이용해서 새로운 state를 만들어 내는 순수함수입니다.
또한 보이는 코드는 쇼핑몰에서 크게 볼 수 있는 장바구니 추가 액션에 대한 코드입니다.
const itemReducer = (state = initialState, action) => {
switch (action.type) {
case ADD_TO_CART:
return Object.assign({}, state, {
cartItems: [...state.cartItems, action.payload]
})
default:
return state;
}
}
보통 위와 같은 모양으로 구성됩니다. 위의 예시에서는 switch문을 통해서 코드를 작성했지만 if문으로 작성해도 무방합니다.
Reducer의 Immutability(불변성)
Reducer 함수를 작성할 때 주의해야 할 점이 있습니다.
바로 Redux의 state 업데이트는 immutable한 방식으로 변경해야 한다는 것인데요.
Redux의 장점 중 하나인 변경된 state를 로그로 남기기 위해서 꼭 필요한 작업입니다.
그렇다면 immutable한 방식으로 state를 변경하기 위해서는 어떻게 코드를 작성해야 할까요?
위의 itemReducer 예제 코드에서 Object.assign을 통해
새로운 객체를 만들어 리턴하는 것을 통해 힌트를 얻을 수 있습니다.
이제 Redux Hooks로 각각의 개념들을 연결시켜 줍시다.
useSelector()
먼저 useSelector()는 컴포넌트와 state를 연결하는 역할을 합니다.
컴포넌트에서 useSelector 메서드를 통해 Store의 state에 접근할 수 있는 것이죠.
어떤 컴포넌트에서 useSelector를 이용해 state에 접근하고 있는지 Cmarket Redux 과제 코드에서 확인해 보세요!
useSelector의 전달인자로는 콜백 함수를 받으며 콜백 함수의 전달인자로는 state 값이 들어갑니다.
자세한 사용법은 공식 문서의 useSelector examples를 참고하세요.
useDispatch()
useDispatch()는 Action 객체를 Reducer로 전달해 주는 메서드입니다.
Action이 일어날만한 곳은 클릭 등의 이벤트가 일어나는 컴포넌트겠죠.
어떤 컴포넌트에서 useDispatch를 이용해 Action을 Reducer로 전달해 줄 수 있을지 고민해 보세요.
그런 다음 공식 문서의 useDispatch() examples를 통해 Dispatch 메서드에 전달인자로
Action이 어떻게 전달되는지 확인하고 코드를 작성해 보세요.
Store
Reducer
// ✅ 구현한 코드 - reducers > itemReducer.js
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:
//TODO
// cartItems 배열에 새로운 아이템을 추가하기 위해 전개 연산자(...)를 사용하여 새로운 배열을 생성하고,
// action.payload를 추가한다. action.payload는 액션 객체에 포함된 추가할 아이템이다.
return { ...state, cartItems: [...state.cartItems, action.payload] };
// {...state} 랑 Object.assign 랑 같음
break;
case REMOVE_FROM_CART:
//TODO
// cartItems 배열에서 action.payload.itemId와 일치하지 않는 아이템만 남기기 위해 filter 메소드를 사용한다.
// 따라서 action.payload.itemId와 일치하는 아이템은 제거된다.
return {
...state,
cartItems: state.cartItems.filter(
(el) => el.itemId !== action.payload.itemId
),
};
break;
case SET_QUANTITY:
let idx = state.cartItems.findIndex(
(el) => el.itemId === action.payload.itemId
);
//TODO
// artItems 배열에서 action.payload.itemId와 일치하는 아이템의 인덱스(idx)를 찾는다.
// 그리고 해당 인덱스를 기준으로 기존 배열을 분할하여 action.payload를 삽입한다.
return {
...state,
cartItems: [
...state.cartItems.slice(0, idx),
action.payload,
...state.cartItems.slice(idx + 1),
],
};
break;
default:
return state;
}
};
export default itemReducer;
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";
// actions creator functions
export const addToCart = (itemId) => {
return {
type: ADD_TO_CART,
payload: {
quantity: 1,
itemId,
},
};
};
export const removeFromCart = (itemId) => {
return {
//TODO
type: REMOVE_FROM_CART,
payload: {
itemId,
},
};
};
export const setQuantity = (itemId, quantity) => {
return {
//TODO
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,
};
};
Dispatch
// ✅ 구현한 코드 - 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() {
const state = useSelector((state) => state.itemReducer);
const { items, cartItems } = state;
const dispatch = useDispatch();
const handleClick = (item) => {
if (!cartItems.map((el) => el.itemId).includes(item.id)) {
//TODO: dispatch 함수를 호출하여 아이템 추가에 대한 액션을 전달하세요.
dispatch(notify(`장바구니에 ${item.name}이(가) 추가되었습니다.`));
// addToCart 액션 생성자 함수는 itemId를 매개변수로 받는다.
// 여기서 item.id가 itemId로 전달된다.
// addToCart 함수 내부에서는 ADD_TO_CART 타입의 액션 객체를 반환한다.
dispatch(addToCart(item.id));
} 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;
// ✅ 구현한 코드 - 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) => {
//TODO: dispatch 함수를 호출하여 액션을 전달하세요.
dispatch(setQuantity(itemId, quantity));
};
const handleDelete = (itemId) => {
setCheckedItems(checkedItems.filter((el) => el !== itemId));
//TODO: dispatch 함수를 호출하여 액션을 전달하세요.
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>
);
}