Redux
리덕스는 이전에 블로그를 만들면서 살짝 다뤄본 적이 있는데 아무 생각없이 따라서 만들기만해서 사실 모르는 것에 가까워서 복습.
왜 사용해야할까?
사실 잘 모르겠다
리덕스를 이제서야 공부하는 이유 중 하나이기도 한데
여태까지 작업한 사이드 프로젝트에서는 context와 useReducer를 이용해서 충분히 상태관리가 가능했기 때문에 리덕스에 눈돌리지 않아서 계속 미루고 있었다
하지만 최근에 작업하는 프로젝트에 기능을 조금씩 붙이다보니 전역으로 관리해야할 상태가 늘어나고, Provider가 충첩되며, 서버에서 받아오는 데이터를 관리하기가 까다로워짐에 따라서 다른 상태관리 툴이 필요하다고 판단되어 눈이 조금씩 돌아가는 중
조금 공부하다 보니 최근에는 서버쪽 데이터를 React Query나 SWR, rtk-query를 사용해서 또 한 번 분리하는 흐름으로 움직이던데
리덕스가 가장 오래되고 사용량이 많으니까 redux => rtk-query => React Query 순으로 공부하려고 한다.
redux-toolkit
리덕스를 도입하지 않은 이유 중 하나가 보일러 플레이트 코드 작성이 너무 많아서 이다.
액션을 정의하고, 그 액션 생성함수를 만들고, 초기 상태값을 만들어서 넣어주고, 리듀서도 작성하고, 리듀서를 또 합치고, 미들웨어도 붙여주고..
관리해야할 상태나 액션이 늘어나면 또 위의 작업을 반복하고, 거기에 타입스크립트를 쓰고 있다면 타입추론이 굉장히 힘들다.
그 불편함들을 모두 해소해주는게 리덕스에서 만든 redux-toolkit인데 공식적으로 쓰는 걸 추천하고 있으니 redux-toolkit으로 진행한다.
slice
하나의 리듀서에 들어가는 액션, 액션 생성자, 리듀서, 초기 상태 4가지를 slice라고 부르는데 createSlice 메서드를 통해서 한 번에 정의할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface CounterState {
value: number;
}
// 초기 상태값 설정
const initialState: CounterState = {
value: 0,
};
const counterSlice = createSlice({
// slice 이름
name: "counter",
// 초기값 부여
initialState,
// 리듀서
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action: PayloadAction<number>) {
state.value += action.payload;
},
decrementByAmount(state, {payload}: PayloadAction<number>) {
state.value -= payload;
},
},
});
export default counterSlice;
이렇게 정의해주게되면 counterSlice
에 위에서 말한 4가지를 라이브러리가 모두 정의해준다.
약간 의아한 점은 redux tutorial 템플릿에서 제공해주는 코드에는 ` incrementByAmount(state, action: PayloadAction
createSlice 내부에 있는 reducer로직에서는 개발자가 이미 판단된 action에 대해서 type값을 사용할 일이 없을 것 같은데 왜 payload만 받지 않고 type이 같이 들어간 action을 받는게 default값인지 모르겠다는 점
잘은 모르지만 미들웨어같은 걸 도입할 때 reducer 로직 내부에서 action의 type값을 사용할 일이 있는 걸까?
그리고 reducers 내부에 정의되는 로직들은 불변성을 신경써줄 필요가 없이 라이브러리가 불변성을 가지고 업데이트를 시켜준다.
acitons
액션 생성자들은 다음과 같이 편하게 뽑아올 수 있다.
export const { increment, decrement, incrementByAmount, decrementByAmount } = counterSlice.actions;
configureStore
configureStore는 기존의 createStore로 하던 부분을 추상화한 함수로 기존에 개발자가 직접해야했던 설정들을 기본적으로 해준다.
1
2
3
4
5
import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'
const store = configureStore({ reducer: rootReducer })
이처럼 타이핑하면 Redux DevTools가 활성화되고, redux-thunk가 추가된다.
좀 더 자세한 설정을 보면
1
2
3
4
5
6
7
const store = configureStore({
reducer,
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(logger),
devTools: process.env.NODE_ENV !== 'production',
preloadedState,
enhancers: [reduxBatch],
})
다른것들은 다 명시적이라 설명이 필요 없을 것 같고
- preloadedState: 스토어의 초기값을 설정할 수 있다.
- enchaners: 기본적으로 배열이며 콜백 함수로 정의되기도 한다. 미들웨어의 순서를 설정하는 옵션
사용하기
1
2
const dispatch = useDispatch();
const { ... } = useSelector((state) => state);
평소처럼 이렇게 사용하면 되겠지만 이러면 typescript와 사용하기 힘들고, 장점을 살릴 수도 없다.
그래서 useDispatch와 useSelector를 한 번 가공해주어야한다.
1
2
export const useAppDispatch = () => useDispatch<{현재 사용하고 있는 리덕스 디스패치의 타입}>();
export const useAppSelector: TypedUseSelectorHook<{현재 사용하고 있는 리덕스 state의 타입}> = useSelector;
이 훅들을 정의해서 사용하면 디스패치를 사용할 때 기존에 정의되지 않은 액션들이 들어올 때 에러를 발견할 수 있고, useSelector를 사용할 때 자동완성의 힘을 빌릴 수 있다.
중괄호 안에 있는 타입들은 configureStore를 정의한 곳에서 뽑아낼 수 있다.
1
2
3
4
const store = configureStore({ reducer: rootReducer })
export type AppDispatch = typeof store.dispatch;
export type RootState = ReturnType<typeof store.getState>;
비동기 액션
RTK에는 기본적으로 redux-thunk와 thunk 액션 생성 함수가 내장되어있고, 이를 이용해서 간단하게 비동기 로직을 적용할 수 있다.
1
2
3
4
5
6
7
8
9
10
export const incrementAsync = createAsyncThunk(
"counter/incrementAsync",
async (amount: number) => {
const response = await new Promise<{ data: number }>((resolve) => {
setTimeout(() => {
resolve({ data: amount });
}, 1000);
});
return response.data;
});
1초 뒤 data를 반환하는 덩크를 만들었다.
이 덩크에 해당하는 액션이 들어오면 내부의 Promise값의 진행도에 따라서 reducer에 counter/incrementAsync/pending
, counter/incrementAsync/fulfilled
, counter/incrementAsync/reject
의 액션이 자동으로 reducer로 들어가게된다
thunk 액션은 reducer 내부에 정의할 수 없기 때문에 외부에서 만들어져 들어오는 action을 처리하기 위한 extraReducers의 콜백에 해당 로직들을 정의한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
//...
},
extraReducers: (builder) => {
builder
.addCase(incrementAsync.pending, (state) => {
console.log("pending...");
state.wait = true;
})
.addCase(incrementAsync.fulfilled, (state, action) => {
console.log("done.");
console.log(action);
state.wait = false;
state.value += action.payload;
});
},
});
콜백은 builder 파라미터를 받으며 builder는 addCase, addMatcher, addDefaultCase 3가지 메서드를 제공하며 각각 chaining이 가능하게 builder 스스로를 반환한다.
- addCase: switch 문에서 리듀서를 정의할 때 쓰는 case와 동일하다.
- addMatcher: 여러 액션을 묶어서 처리할 때 사용.
isAnyOf
,isAllOf
과 같이 제공되는 메서드를 이용 - addDefaultCase: default 와 동일
위에서 말했듯이 3가지 상태로 extarReducers에 전달되며 이전과 같이 state값을 수정해주면 불변성을 유지하며 상태가 업데이트된다.
에러 핸들링
reducer에서
createAsyncThunk가 반환하는 promise는 결과와 상관없이 항상 이행된(fulfilled)상태로 반환해준다.
그래서 에러를 잡기 위해서는 조금 더 손을 써줘야한다.
1
2
3
4
5
6
7
8
9
10
11
export const someAsync = createAsyncThunk(
"someAsync",
async () => {
try{
const response = await fetchData();
return response.data;
} catch (e){
return e;
}
});
무조건 fulfilled를 반환한다는 것은 위의 fetchData()에서 데이터를 제대로 불러오지못해서 에러가 발생해도 fulfilled 처리되어 ` .addCase(incrementAsync.fulfilled, (state, action)`문이 실행된다.
에러가 발생했는데도 콘솔에는 done.이 찍히는 모습
1
2
3
4
5
6
7
8
9
10
11
export const someAsync = createAsyncThunk(
"someAsync",
async (_, { rejectWithValue }) => {
try{
const response = await fetchData();
return response.data;
} catch (e){
return rejectWithValue(e);
}
}
);
덩크 생성자의 두번째 파라미터에 rejectWithValue를 이용하면 reject된 promise를 보낼 수 있다.
컴포넌트 내부에서
리듀서에서도 에러핸들링을 할 수 있지만 컴포넌트 내부에서도 할 수 있다.
이 쪽이 컴포넌트 별로 다르게 에러를 핸들링할 수 있다는 점에서 조금 더 권장되는 방법
1
2
3
4
5
6
7
8
9
10
11
import { unwrapResult } from "@reduxjs/toolkit";
const handleEvent = async () => {
try {
const resultAction = await dispatch(asyncFucntion(value));
const fulfilled = unwrapResult(resultAction);
//성공
} catch (reject) {
//실패
}
};
디스패치로 받아온 결과값을 unwrapResult 메서드로 벗겨내는 과정에서 결과값이 정상이면 성공 로직을 에러가 난다면 catch문에서 실패 로직을 처리해주면된다.
비동기 취소하기
시작 전
createAsyncThunk의 3번째 인자로 들어가는 option에서 condition 콜백에서 true, false로 비동기 로직 시작 전에 취소시킬 수가 있다.
condition의 첫 번째 인자 : 덩크를 실행시킬 때 전달받은 값 condition의 두 번째 인자 : Pick<thunkAPI, ‘getState’, ‘extra’>
1
2
3
4
5
6
7
8
9
10
11
12
13
export const someAsync = createAsyncThunk(
"someAsync",
async (_) => {
try{
const response = await fetchData();
return response.data;
} catch (e){
return rejectWithValue(e);
}
}, { conditon: (_, {getState, extra}) =>{
// 비교로직
}}
);
보통 첫 번째 인자로 받은 값과 getState로 현재 state를 비교해서 true/false로 취소 결정을 하는 것 같다
시작 중
이미 시작되어버린 비동기 로직도 디스패치의 promise.abort() 메서드를 통해 취소하는 것이 가능하다.
1
2
3
4
const handleEvent = () => {
const promise = dispatch(asyncFucntion(value));
promise.abort();
};