먼저 기본으로 이해해야 하는 부분은, createSlice의 reducers 내에서는 httpRequest와 같이 side Effect를 일으키는 코드를 작성할 수 없다는 것이다.
Redux에서 state를 derive하는 Reducer와, SideEffect는 무슨 연관이 있을까?
먼저, Redux에서 사용하는 Reducer 함수는, 반드시 순수 함수여야 하며(외부 요소에 영향을 받지 않는,Side Effect가 없는 함수)동기식이어야 한다.
Reducer 함수의 state는 필수 input(initialState)을 받으며 action을 통해 output(derive)을 만들어 낸다.
위와 같은 특징은 꼭 Redux의 Reducer함수에게만 해당되는 것은 아니다.
React-hook인 useReducer도 같은 방식으로 작동한다.
즉, 일반적인 Reducer의 개념으로는 input을 받고, output을 생성하는 Side-Effect가 있는 동기식 함수라 할 수 있다.
Reducer 함수는 같은 값을 입력값으로 받을 때, 비동기로 작동하는 코드가 없거나, Side Effect가 없다면 항상 동일한 아웃풋을 생성한다.
즉 Side Effect를 일으키는 코드나, 비동기로 작성된 코드는 Reducer 함수의 일부가 될 수 없다.
그렇다면 HTTP Request처럼, Side-Effect가 발생하는 action을 전달하는 코드를 Redux에서 사용해야 할 때, Redux의 어느 부분에 작성해야 하는지 의문이 생길 수 있다.
그 의문에 대한 답으로, Side-Effect를 두 개의 위치에 둘 수 있다로 말할 수 있겠다.
컴포넌트 내에서 Http Request와 같은 Side Effect를 일으키는 코드 작성
useEffect를 사용하여 컴포넌트에 직접 비동기 코드를 작성하고,
그 다음 해당 Side-Effect가 완료된 후 받은 결과값을 action을 통해 전달한다.
컴포넌트에서 Request했기 때문에 Reducer는 Side-Effect에 대해 알지 못하고, 컴포넌트 내에서 action creator를 작성한 것처럼 동작하게 만든다.
//useEffect의 의존성 배열을 사용하여
//cart에 변경 사항이 생길 때 http request가 진행되게 하여 Reducer의 No Side-Effect를 유지한다.
useEffect(() => {
const sendCartData = async() => {
dispatch(uiSliceActions.showNotification({
status:'pending',
title:'Sending..',
message:'Sending cart data',
}))
const response = await fetch(`https://carttest-c0095-default-rtdb.asia-southeast1.firebasedatabase.app/cart.json`,
{
method: 'PUT',
body: JSON.stringify(cart),
}
);
if(!response.ok){
throw new Error('Sending cart data failed.')
}
dispatch(uiSliceActions.showNotification({
status:'success',
title:'Success',
message:'success sending data',
}))
}
if(isInitial){
isInitial = false;
return;
}
sendCartData().catch(error => {
dispatch(uiSliceActions.showNotification({
status:'error',
title:"Error!",
message:'failed sending cart',
}))
});
}, [cart,dispatch]);
return (
<>
{notification && <Notification status={notification.status} message={notification.message} title={notification.title}/>}
<Layout>
{' '}
{cartIsVisible && <Cart />} <Products />
</Layout>
</>
);
}
toolkit을 통해 자동으로 생성된 action creator를 사용 해왔다.
비동기 처리를 하기 위해서는 action creator에 thunk를 사용한다.
thunk는 다른 action이 완료될 때까지 해당 action을 지연시키는 단순한 함수라고 할 수 있다.
즉 비동기 처리의 결과를 받는 action object를 담은 action creator의 실행을 지연시키는 것이 thunk이다.
//cart-actions.js
import { uiSliceActions } from "./UiSlice";
import { MyCartSliceActions } from "./MyCartSlice";
export const fetchCartData = () => {
return async (dispatch) => {
const fetchData = async () => {
const response = await fetch('https://carttest-c0095-default-rtdb.asia-southeast1.firebasedatabase.app/cart.json')
if(!response.ok){
throw new Error('Could not fetch cart data')
}
const data = await response.json()
return data
}
try{
const cartData = await fetchData();
dispatch(MyCartSliceActions.replaceCart({
items: cartData.items || [],
...cartData
}))
}catch(err){
dispatch(uiSliceActions.showNotification({
status:'error',
title:"Error!",
message:'failed fetching cart data',
}))
}
}
};
// Slice 객체의 외부에서 Thunk를 생성한다.
// Thunk는 Reducer 함수 내부에 존재하는게 아니므로, 비동기 코드와 Side-Effect를 수행할 수 있다.
export const sendCartData = (cart) => {
return async (dispatch) => {
dispatch(
uiSliceActions.showNotification({
status: 'pending',
title: 'Sending..',
message: 'Sending cart data',
})
);
const sendRequest = async () => {
const response = await fetch(
`https://carttest-c0095-default-rtdb.asia-southeast1.firebasedatabase.app/cart.json`,
{
method: 'PUT',
body: JSON.stringify({
items: cart.items,
totalQuantity: cart.totalQuantity
}),
}
);
if (!response.ok) {
throw new Error('Sending cart data failed.');
}
};
try{
await sendRequest()
dispatch(
uiSliceActions.showNotification({
status: 'success',
title: 'Success',
message: 'success sending data',
})
);
}catch(err){
dispatch(uiSliceActions.showNotification({
status:'error',
title:"Error!",
message:'failed sending cart',
}))
}
};
};
import { createSlice } from '@reduxjs/toolkit';
const MyCartSlice = createSlice({
name: 'cart',
initialState: {
items: [],
totalQuantity: 0,
changed: false,
},
reducers: {
replaceCart(state,action){
state.totalQuantity = action.payload.totalQuantity;
state.items = action.payload.items;
},
addItemToCart(state, action) {
const newItem = action.payload;
const itemToBeContain = state.items.find((item) => item.id === newItem.id);
state.totalQuantity++;
state.changed = true;
console.log(newItem);
if (!itemToBeContain) {
state.items.push({
id: newItem.id,
price: newItem.price,
quantity: 1,
totalPrice: newItem.price,
title: newItem.title,
});
} else {
itemToBeContain.quantity++;
itemToBeContain.totalPrice =
itemToBeContain.totalPrice + itemToBeContain.price;
}
},
removeItemFromCart(state, action) {
const id = action.payload;
const removeItem = state.items.find((item) => item.id === id);
state.changed = true;
state.totalQuantity--;
if (removeItem.quantity === 1) {
state.items = state.items.filter((item) => item.id !== id);
console.log('now filter working');
} else {
removeItem.quantity--;
removeItem.totalPrice = removeItem.totalPrice - removeItem.price;
}
},
},
});
export default MyCartSlice;
export const MyCartSliceActions = MyCartSlice.actions;
import Cart from './components/Cart/Cart';
import Layout from './components/Layout/Layout';
import Products from './components/Shop/Products';
import { useSelector,useDispatch } from 'react-redux';
import { useEffect } from 'react';
import { fetchCartData, sendCartData } from './store/cart-actions';
import Notification from './components/UI/Notification';
let isInitial = true; // 컴포넌드랜더링과 상관없이 바깥에서 초기화
function App() {
const cartIsVisible = useSelector((state) => state.ui.cartIsVisible);
// useSelector는 store를 구독중인 상태이며, 스토어가 변경될 때마다 컴포넌트 함수가 다시 실행되어 최신 상태를 유지한다.
const cart = useSelector((state) => state.cart);
const dispatch = useDispatch();
const notification = useSelector(state => state.ui.notification)
//useEffect의 의존성 배열을 사용하여
//cart에 변경 사항이 생길 때 http request가 진행되게 하여 Reducer의 Side-Effect가 없는 순수 함수를 유지하게 한다.
useEffect(()=>{
dispatch(fetchCartData())
},[dispatch])
useEffect(() => {
if(isInitial){
isInitial = false
return;
}
// dispatch로 sendCartData를 사용할 수 있는 이유는 sendCartData가 dispatch를 반환하는 함수이기 때문이다.
if(cart.changed){
dispatch(sendCartData(cart))
}
}, [cart,dispatch]);
return (
<>
{notification && <Notification status={notification.status} message={notification.message} title={notification.title}/>}
<Layout>
{' '}
{cartIsVisible && <Cart />} <Products />
</Layout>
</>
);
}
export default App;
redux-toolkit의 createSlice를 이용한 경우, thunkMiddleware가 기본적으로 추가된다. Thunk는 action creator에 객체가 아닌 함수 타입을 사용하여 비동기 코드도 사용할 수 있게 한다.(물론 함수도 객체지만.)