redux toolkit 사용해보기
리덕스 툴킷을 왜 쓰는걸까?
리덕스를 사용할때 기본적으로 만들어야 하는 코드는 다음과 같다.
- 초기 상태 정의
- 액션 타입 정의
- 액션 생성함수 정의
- 리듀서 정의
이 4가지를 모두 수행해야 한다. 그렇다면 상태와 action이 굉장히 많은 경우에는 어떻게 될까? 일단 리덕스를 편하게 사용하기 위한 액션 타입, 액션 생성함수들의 코드량이 엄청 많을뿐만 아니라 액션들의 이름도 겹칠 수 있다. 또한 리덕스의 상태는 읽기 전용이다. 값을 하나만 바꾸고자 한다고 해도 기존 상태값을 복사해와서 계속해서 사용해야 한다. 리듀서 함수도 점점 길어지고 거대해질 것이다.
이러한 문제점을 해결하고자, 리덕스에서는 리덕스를 조금더 간편하게 사용할 수 있는 toolkit
을 만들었다.
어떻게 변했나?
- 초기 상태 정의
- 리듀서 정의만 하면 된다!
// 기존 리덕스를 사용했을 때는 아래와 같이 진행했다. const initialState = { counter: 0 }; const INCREASE = 'counter/INCREASE'; const DECREASE = 'counter/DECREASE'; export const increase = () => { return { type: INCREASE, }; }; export const decrease = () => { return { type: DECREASE, }; }; // action 타입과 action creator 생성 const counterReducer = (state = initialState, action) => { if (action.type === INCREASE) { return { counter: state.counter + 1, }; } if (action.type === DECREASE) { return { counter: state.counter - 1, // 상태의 불변성을 위해, 이 부분을 꼭 지켜주어야 한다. }; } return state; }; export default counterReducer;
리덕스 툴킷을 사용할 때에는 이처럼 적용할 수 있다.
const initialState = { counter: 0, showCounter: true, }; const counterSlick = createSlice({ name: "counter", // 모든 slice는 이름이 필수 initialState, // 위의 초기 상태를 그대로 가져올 수 있음 reducers: { // 이 reducers 의 이름은 바꿀 수 없다. 오타나니까 actions 객체가 안만들어져서 깜짝 놀람 increment(state) { state.counter++; // 예전에는 이렇게 하면안되었지만, // 여기서는 이렇게 보일뿐 기존상태를 변경하지 않고 새로운 상태를 반환한다. // 어차피 redux toolkit에서는 기존 상태를 변경불가로-이뮤터블로 둔다. // 조금 더 쉽게 리덕스를 사용할 수 있다. // 기존에는, ...state, state.counter + 1 // 로 사용했을 것이다. }, decrement(state) { state.counter--; }, // 위 두개는 payload가 없으므로 action도 필요없다. increase(state, action) { state.counter += action.amount; }, // payload가 필요한 부분은 action이 필요하다. toggle(state) { state.showCounter = !state.showCounter; }, }, // 모든 슬라이스는 리듀서를 필요로 함. // 여기에다가 메서드를 추가하면 됨. // 나중에 이 이름이 중요한 역할을 함 // action은 필요 없음 <- 어떤 action을 했음에 따라 함수를 호출할것임 }); // 객체를 인자로 받는다.
// 위에 만들어둔 slice를 이와 같이 store에 연결할 수 있다. const store = createStore(counterSlice.recuders); // 이렇게 스토어를 만들어서 연결하면 된다. 스토어가 여러개라면 아래와 같이 const store = configureStore({ reducer: counterSlice.reducer, }); // configureStore를 활용한다.
redux toolkit
에서 상태의 불변성을 지켜주므로, 상태값에 그대로 접근해서 상태를 수정할 수 있다. redux
를 사용했을 때와는 다른점이다.
덧붙여, 액션생성자 함수나 액션타입을 정의해주는 것을 하지 않아도 된다. toolkit
에서 자체적으로 액션생성자함수를 만들어 주므로 우리는 간단하게 사용할 수 있다.
액션 객체를 dispatch하기
// index.js (store) export const counterActions = counterSlice.actions; // counterActions를 export 한다. // 그리고 이 액션이 필요한 파일로 간다. // 위에 만들었던 액션생성자함수, 액션객체를 알아서 만들어준다. // 우리는 단순히 이런식으로 액션들을 넘겨주면 된다.
// counterActions를 사용하는 곳에서, // Counter.js import { useSelector, useDispatch } from "react-redux"; import { counterActions } from "../store/index"; import classes from "./Counter.module.css"; const Counter = () => { const dispatch = useDispatch(); const counter = useSelector((state) => state.counter); const show = useSelector((state) => state.showCounter); const toggleCounterHandler = () => { dispatch(counterActions.toggle()); // 기존에 액션생성자함수를 사용해서 dispatch하던것과 비슷하게 그대로 사용할 수 있다. }; const incrementHandler = () => { dispatch(counterActions.increment()); }; const decrementHandler = () => { dispatch(counterActions.decrement()); }; const increaseHandler = () => { dispatch(counterActions.increase(5)); // { type : SOME_UNIQUE, payload : value } }; // 이런식으로 들어가기 때문에 새로받는 요소는 payload로 작성해주어야 한다. return ( <main className={classes.counter}> <h1>Redux Counter</h1> {show && <div className={classes.value}>{counter}</div>} <div> <button onClick={incrementHandler}>INCREMENT</button> <button onClick={increaseHandler}>INCREMENT by 5</button> <button onClick={decrementHandler}>DECREMENT</button> </div> <button onClick={toggleCounterHandler}>Toggle Counter</button> </main> ); }; export default Counter;
호출을 하는 비동기 처리를 할 일이 있다. 그런데 이러한 호출들은 순수함수가 아니고 사이드이펙트를 만들어낸다. 리덕스의 기본원칙에 따르면 리듀서들은 순수함수들이어야 하고 사이드이펙트를 만들지 않는다. 그렇다면, 이러한 비동기 처리는 어디서 이루어져야 하나?
- component 단위로
useEffect
를 사용한다. redux
의action creators
에서 처리한다.
이러한 두가지 방법이 있다.
- 이렇게 나누어서 생각해보니, 어디서 비동기처리를 하는게 좋은가? 에 대한 고민도 시작되었다.
컴포넌트에서 비동기 처리하기
첫번째는 그냥 컴포넌트에서 useEffect
훅을 이용해서 비동기 처리하는 것이다.
import Cart from "./components/Cart/Cart"; import Layout from "./components/Layout/Layout"; import Products from "./components/Shop/Products"; import { useEffect } from "react"; import { useSelector } from "react-redux"; function App() { const showCart = useSelector((state) => state.ui.cartIsVisible); const cart = useSelector((state) => state.cart); useEffect(() => { fetch(" MY FIREBASE ", { method: "PUT", body: JSON.stringify(cart), // 기존 데이터에 override 하기 위해 PUT 요청 }); }, [cart]); return ( <Layout> {showCart && <Cart />} <Products /> </Layout> ); } export default App;
어떻게 보면 이는 리덕스와 상관없이 예전부터 사용하던 방식이고 이렇게 한다고 해도 문제가 없다.
리덕스에서 상태를 불러와서, 현재의 상태가 바뀔때마다 put
요청으로 비동기 처리를 한다.
이렇게 보면 부수적인-사이드 이펙트를 만들어내는- 로직들을 리덕스에 두지 않는 다는 점에서 장점이 있는 것 같다. 다만, 전역적으로 사용해야 하는 api
호출이 있다면 문제가 있을 것 같다는 생각이 들었다.
redux thunk로 해결하기
slice
를 만들어둔 파일 안에서action creator
를 만들어 사용한다.
// 특정 `dispatch` 를 반환하는 함수를 만든다. // Cart-slice.js const sendCartData = (cart) => { return async (dispatch) => { dispatch( uiActions.showNotification({ status: "pending", title: "sending...", msg: "sending cart data", }), ); const sendRequest = async () => { const response = await fetch( "<https://test-234b2-default-rtdb.firebaseio.com/cart.json>", { method: "PUT", body: JSON.stringify(cart), }, ); if (!response.ok) { throw new Error("Sending cart data failed."); } }; try { await sendRequest(); dispatch( uiActions.showNotification({ status: "success", title: "Success...", msg: "sending cart data successfully", }), ); } catch (error) { dispatch( uiActions.showNotification({ status: "error", title: "error...", msg: "failed", }), ); } }; }; // 원래 리덕스는 이렇게 action creator을 만들어서 사용했는데 툴킷에서 이런걸 만들어주어서 // 툴킷에서 따로 만들지 않았다.
let isInitial = true; function App() { const dispatch = useDispatch(); const showCart = useSelector((state) => state.ui.cartIsVisible); const cart = useSelector((state) => state.cart); const notification = useSelector((state) => state.ui.notification); useEffect(() => { if (isInitial) { isInitial = false; return; } dispatch(sendCartData(cart)); // 이렇게 dispatch로 cart-slice에서 정의된 위 함수를 불러오면, 그대로 사용할 수 있다. }, [cart, dispatch]); return ( <> {notification && ( <Notification status={notification.statue} title={notification.title} message={notification.msg} /> )} <Layout> {showCart && <Cart />} <Products /> </Layout> </> ); } export default App;
createAsyncThunk 사용하기
export const fetchCart = createAsyncThunk("fetchCart", async () => { return await fetch("url") .then((r) => r.json()) .then((r) => console.log(r)); }); // createAsyncThunk를 만든다.
const cartSlice = 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 existingItem = state.items.find((item) => item.id === newItem.id); state.totalQuantity++; state.changed = true; if (!existingItem) { state.items.push({ id: newItem.id, price: newItem.price, quantity: 1, totalPrice: newItem.price, name: newItem.title, }); } else { existingItem.quantity++; existingItem.totalPrice = existingItem.totalPrice + newItem.price; } }, removeItemFromCart(state, action) { const id = action.payload; const existingItem = state.items.find((item) => item.id === id); state.totalQuantity--; state.changed = true; if (existingItem.quantity === 1) { state.items = state.items.filter((item) => item.id !== id); } else { existingItem.quantity--; existingItem.totalPrice = existingItem.totalPrice - existingItem.price; } }, }, extraReducers: { [fetchCart.fulfilled]: (state, action) => { state.status = "Fulfilled"; state.data = action.payload; }, }, // extraReducers에 추가해준다. // fulfilled를 제외하고도 // pending, reject 상태도 관리할 수 있다. });