다음 Cmarket Shopping App은 Create React App으로 만든 React 애플리케이션에 Redux를 붙인 구조입니다.
아이템 리스트 페이지(ItemListContainer)와 장바구니 페이지(ShoppingCart) 총 두 페이지로 간단하게 구성됩니다.
Store의 initial state에는 전체 아이템 목록(items), 장바구니 목록(cartItems)이 들어있습니다.
각 ItemListContainer, ShoppingCart 페이지 컴포넌트 및 components 폴더의 여러 컴포넌트들에서 Store(state)에 접근해 보세요.
(Redux에서 제공하는 hooks, useDispatch, useSelector를 사용합니다.)
이 과정을 통해 Action, Dispatch, Reducer, Store가 어떻게 유기적으로 연결되어 있는지 배우실 수 있습니다. 앞선 강의에서 학습한 각각의 개념들을 다시 정리해 볼까요?
Action
Action은 말 그대로 어떤 액션을 취할 것인지 정의해 놓은 객체입니다.1.{ type: ‘ADD_TO_CART’, payload: request }
보통 위과 같은 모양으로 구성됩니다. 여기서 type은 필수로 지정을 해 주어야 하며, 그 외의 것들은 선택적으로 사용할 수 있습니다.
이렇게 모든 변화를 Action을 통해 취하는 것은 우리가 만드는 앱에서 무슨 일이 일어나고 있는지 직관적으로 알기 쉽게 하는 역할을 합니다.
actions > index.js 파일에서는 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이 어떻게 전달되는지 확인하고 코드를 작성해 보세요.
핵심은 UseSelector와 UseDispatch를 이용하는 것이다.
https://react-redux.js.org/7.1/api/hooks --- 공식 문서
유저가 카트에 담기 버튼 누르면 ----- components/ Item.js 에서 handleclick 발생
handle click은 addToCart 호출 ----- pages/ ItemListContainer.js
addToCart는 ADD_TO_CART 호출 ----- actions/ index.js
ADD_TO_CART는 addToCart payload값을 index.js (reducers 디렉토리 내의 파일)에 전달
----- reducers/ itemReducer.js
index.js에서는 상태와 액션값을 store로 전달 ----- reducers/index.js
store.js는 루트 경로의 index.js로 값들을 전달 --- store/store.js
받은 값들 토대로 리렌더링 ----- ./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
}
}
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(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;
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>
)
}
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
return Object.assign({}, state, {
cartItems: [...state.cartItems, action.payload]
})
break;
case REMOVE_FROM_CART:
//TODO
let currentItem = state.cartItems.filter((el) => el.itemId !== action.payload.itemId)
return Object.assign({}, state, {
cartItems: currentItem
})
break;
case SET_QUANTITY:
let idx = state.cartItems.findIndex(el => el.itemId === action.payload.itemId)
//TODO
return {
...state,
cartItems: [...state.cartItems.slice(0, idx), action.payload, ...state.cartItems.slice(idx + 1)]
}
break;
default:
return state;
}
}
export default itemReducer;