Redux
는 Flux
아키텍쳐의 구현체 입니다.
Redux 공식 홈페이지에서는 Redux를 A Predictable State Container for JS Apps 라고 표현하고 있습니다. React
와 함께 Redux
를 사용하는 경우가 많지만 정확히는 React
만을 위한 라이브러리는 아닙니다.
React
의 상태(State) 중 전역적으로 사용해야하는 상태를 Redux
를 통해 관리하는 구조라고 할 수 있습니다.
여러가지 장점을 주는 Redux에게도 큰 단점이 존재했는데 그것은 다음과 같습니다.
- Configuring a Redux store is too complicated
- I have to add a lot of packages to get Redux to do anything useful
- Redux requires too much boilerplate code
이러한 이유로 Redux를 사용하기 위한 진입장벽이 높았고, 생산성도 좋지 못했습니다.
그래서 Redux Toolkit이 탄생하였습니다.
Redux Toolkit에서는 redux-actions, redux-thunk, immer, reselect
등의 여러가지 라이브러리 기능을 모두 지원합니다.
예제들을 통해 어떻게 Redux Toolkit으로 Redux를 쉽게 사용하는지 알아보도록 하겠습니다.
ConfigureStore
에서는 reducer, middleware, devTools, preloadedState
등을 설정 할 수 있습니다. reducer
만 필수이고 나머지는 optional
입니다.
import { configureStore } from "@reduxjs/toolkit";
export default configureStore({
reducer: {},
});
confiureStore
를 만들었다면 <App />
을 Provider
로 감싸줍니다.
redux의 상태를 App전체에서 사용해야하기 때문에 가장 상단의 App에 Provider
를 감싸주어야 합니다.
//...
import { Provider } from 'react-redux';
import store from './app/store';
// ...
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
initialState, action, reducer
를 한번에 정의합니다.
import { PayloadAction, createSlice } from "@reduxjs/toolkit";
export const countSlice = createSlice({
name: "count",
initialState: 0,
reducers: {
add: (state, actions: PayloadAction<number>) => {
return state + actions.payload;
},
},
});
// this is for dispatch
export const { add } = countSlice.actions;
// this is for configureStore
export default countSlice.reducer;
count
라는 상태는 기본값(initialState)
가 0
이고, 한가지 액션 add
를 취할 수 있습니다.
createSlice
를 사용하여 리듀서와 액션을 모두 한번에 정의해주었으니 이걸 나누어서 configureStore
에 직접 reducer를 넣어주고 사용하는 컴포넌트에서는 action을 가져와서 사용해야합니다.
import { configureStore, combineReducers } from "@reduxjs/toolkit";
import count from "./ducks/count";
const ReducerRoot = combineReducers({
count,
});
const store = configureStore({
reducer: ReducerRoot,
});
export default store;
export type RootState = ReturnType<typeof ReducerRoot>;
위와 같이 combineReducers
를 활용하여 configureStore
의 reducer
에 count
를 넣어줍니다.
원래라면 각 reducer
마다 어떤 데이터 타입을 사용하는지 명세해야하고, reducer
의 개수가 늘어나는 경우 configureStore
가 비대해지지만 combineReducers
를 활용하면 이를 간단하게 해결할 수 있습니다.
전역상태 count
를 늘리고 줄일 수 있는 Test Component
를 만드려합니다.
먼저 count
값을 가져와야합니다 !!
import { useSelector } from "react-redux";
import { RootState } from "./store";
const Test = () => {
const count = useSelector((state: RootState) => state.count);
return (
<div>
<p>{count}</p>
);
};
export default Test;
현재 Test Component에서는 count의 값을 가져오고 있습니다.
미리 선언해준 RootState
타입을 활용하여 state에 count가 있다는 사실도 코드상으로 확인할 수 있습니다.
지금은 state
를 count
그대로 사용했지만 useSelector
는 전역상태를 해당 컴포넌트에 맞게 serialize 하거나 필터링하는데도 사용할 수 있습니다.
예를 들어 다음과 같은 메시지 객체들이 있다고 가정해봅시다.
[
{"subscribed": true, "message": "welcome !", createdAt: '2022-02-13'},
{"subscribed": false, "message": "LGTM :)", createdAt: '2022-02-14'},
{"subscribed": true, "message": "Red Kiwi !", createdAt: '2022-02-15'},
]
해당 데이터 중 subscribe
가 된 객체만 사용한다고 하면 다음과 같이 코드를 작성하면 됩니다.
const chats = useSelector(
(state) => state.message.filter(msg => msg.subscribed)
);
이제 count
값을 바꿔봅시다 !
Flux 패턴
에 따르면 모든 Action
은 Dispatcher
를 거져 Store
를 변경해야합니다.
Action
이 어떤 동작을 하는지는 createSlice
에서 정의해주었으니 Test Component
에서 사용해봅시다 !
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "./store";
import { add } from "./ducks/count";
const Test = () => {
const count = useSelector((state: RootState) => state.count);
const dispatch = useDispatch();
return (
<div>
<p>{count}</p>
<div>
<button onClick={() => dispatch(add(1))}>+1</button>
<button onClick={() => dispatch(add(5))}>+5</button>
<button onClick={() => dispatch(add(-1))}>-1</button>
<button onClick={() => dispatch(add(-5))}>-5</button>
</div>
</div>
);
};
export default Test;
버튼이 4개가 있고 각각 1, 5를 더해주고 빼주는 기능을 합니다.
잘 작동하는 모습입니다 🙂
지금까지는 모두 동기적인 작업이었습니다.
만약 상태가 서버의 상태에 따라 달라지고 따라서 상태가 변경되는데 시간이 걸리는 비동기적으로 작동해야한다면 어떻게 해야할까요?
createAsyncThunk
는 Action
의 비동기 작업을 위해 만들어졌습니다.
Promise
가 Pending, fulfilled, reject
된 경우에 각각에 대한 상태 관리를 reducer
에서 가능하도록 합니다.
const requests = async ({
delay,
count,
}: {
delay: number;
count: number;
}): Promise<number> => {
console.log(`request :: delay : ${delay} count : ${count}`);
return await new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() * 5 < 1) {
reject("Error");
}
resolve(count);
}, delay);
});
};
export { requests };
fetch
요청과 같은 비동기 함수를 가정하기 위해 delay
ms 후에 count
만큼 값을 반환해주는 함수를 작성하였습니다. 추가로 요청이 에러를 응답하는 상황도 함께 보기 위해 20%
로 reject
되도록 하였습니다.
이제 Thunk를 어떻게 작성하는지 알아봅시다 !!
export const sleepAndAdd = createAsyncThunk<
number,
{ delay: number; count: number }
>("count/sleepAdd", async ({ delay, count }) => {
const add = await requests({ delay, count });
return add;
});
TypeScript
에서 createAsyncThunk
함수는 제네릭으로 타입을 지정해주어야 합니다.
제네릭의 첫번째 인자는 어떤 타입을 반환할건지, 두번째 인자는 어떤 타입을 패러미터로 받을 것인지, 세번째 인자는 optional
인데 thunkApi
field type을 넣어주게 됩니다.
위 코드같은 경우 number
타입을 반환하고, 인자로 {delay: number, count: number}
를 받는 함수가 됩니다.
createAsyncThunk
함수의 인자는 첫번째는 action의 타입, 두번째 인자는 callback function
입니다. 여기에서 이야기하는 action의 타입이라는 것은 타입스크립트의 타입은 아닙니다.
redux
의 모든 action은 TYPE
이라는 것으로 구분되는데, 기존에 만들어주었던 동기적인 action들은 createSlice
로 만들었기 때문에 자동적으로 TYPE
이 생성되어 들어간 것입니다.
내부적으로는 ‘${name}/${action_name}’
으로 TYPE
이 자동 생성된다고 합니다.
이제 해당 Thunk
가 실행될 때 각각의 상황에서 어떻게 동작하는지 정의해주어야 합니다.
export const countSlice = createSlice({
name: "count",
initialState: { isLoading: false, value: 0 },
reducers: {
add: (state, actions: PayloadAction<number>) => {
return {
value: state.value + actions.payload,
isLoading: state.isLoading,
};
},
},
extraReducers: (builder) => {
builder.addCase(sleepAndAdd.pending, (state) => {
state.isLoading = true;
});
builder.addCase(sleepAndAdd.fulfilled, (state, actions) => {
state.isLoading = false;
state.value += actions.payload;
});
builder.addCase(sleepAndAdd.rejected, (state) => {
state.isLoading = false;
console.log("20% 확률로 rejected !!");
});
},
});
비동기 함수가 추가됨에 따라 로딩이라는 개념도 생겼기 때문에 먼저 initialState
를 변경해줍니다.
이후에는 extraReducers
에 위와같이 정의해줍니다. pending
상태와 fulfilled
상태인 경우를 정의해주었는데, pending
인 경우는 isLoading
을 true
로 바꿔주고, fulfilled
인 경우는 isLoading
을 false
로 바꾸고, value
를 payload
만큼 더해줍니다.
이때 여기에서 말하는 payload
란 createAsyncThunk
에서 정의해주었던 리턴값을 이야기합니다.
따라서 위 코드에서 payload
의 타입은 number
가 됩니다.
reject
된 경우는 console.log
로 오류 발생을 기록하고 isLoading
만 false
로 바꿔줍니다.
///...
export type AppDispatch = typeof store.dispatch;
비동기 액션을 dispatch 하기위해서는 먼저 useDispatch
에 제네릭으로 타입을 명시해주어야 합니다.
configureStore
가 있던 store.ts
로 돌아가 AppDispatch
type을 명시해줍니다.
import { useDispatch, useSelector } from "react-redux";
import { RootState, AppDispatch } from "./store";
import { add, sleepAndAdd } from "./ducks/count";
const Test = () => {
const count = useSelector((state: RootState) => state.count.value);
const isLoading = useSelector((state: RootState) => state.count.isLoading);
const dispatch = useDispatch<AppDispatch>();
const asyncBtnHandler = async () => {
await dispatch(sleepAndAdd({ delay: 1000, count: 1 }));
};
return (
<div>
{!isLoading && <p>{count}</p>}
<div>
<button onClick={() => dispatch(add(1))}>+1</button>
<button onClick={() => dispatch(add(5))}>+5</button>
<button onClick={() => dispatch(add(-1))}>-1</button>
<button onClick={() => dispatch(add(-5))}>-5</button>
<button onClick={asyncBtnHandler}>sleep and + 1</button>
</div>
</div>
);
};
export default Test;
이제 모든 준비가 완료되었습니다. count
와 isLoading
을 useSelector
로 받아줍니다.
비동기 액션이 실행될 함수도 만들어주고, 로딩중에는 count
가 보이지 않도록 구현해줍니다.
잘 작동하는 것을 확인할 수 있고, 20% 확률로 reject
되는 경우도 잘 처리하고 있습니다.
지금은 비동기 버튼을 누르는대로 요청이 마구마구 보내지게 됩니다.
요청이 보내지는 도중에 추가적인 요청을 막으려면 어떻게 해야할까요 ?
export const sleepAndAdd = createAsyncThunk<
number,
{ delay: number; count: number }
>(
"count/sleepAdd",
async ({ delay, count }) => {
const add = await requests({ delay, count });
return add;
},
{
condition: (arg, { getState }) => {
const { count } = getState() as any;
if (count.isLoading) {
alert("이미 요청이 진행중입니다.");
return false;
}
},
}
);
createAsyncThunk
는 실은 3번째 인자가 있습니다. 위와 같이 3번째 인자에 condition 함수를 정의해주는 경우 요청이 보내지기 전에 Thunk
를 취소할 수 있습니다.
condition
함수가 false
를 반환하면 Thunk
가 취소되고 그렇지 않은 경우 그대로 실행되게 됩니다.
정리하자면 리덕스의 데이터 흐름은 아래 그림과 같습니다. (출처)
redux-saga, middleware, createSelector
등 아직 리덕스에 대해 공부해야할 부분은 많지만 간단한 케이스로부터 동기, 비동기적으로 다루어봤다는 점에서 의미가 있는 것 같습니다.
리덕스를 공부하며 많은 개념들이 잘 녹아있는 라이브러리라는게 느껴졌습니다.
redux-toolkit
를 활용했음에도 많은 개념들이 녹아있기에 러닝커브가 높고, 복잡하다는 인상을 받았습니다. 하지만 이러한 개념들을 잘 이해하고 잘 사용한다면 기존의 recoil, react-query
등 여러 라이브러리를 사용해야하는 것에 비해 리덕스 커뮤니티로 모두 구현이 가능할 것 같다는 생각이 들었습니다.
읽어주셔서 감사합니다.