어디서나 볼 수 있는 카운터 애플리케이션 만들어보기 (feat. RTK)
우선 공식문서에서 추천하는 구조로 애플리케이션을 만들어 보고자 합니다.
- /src
- index.ts
- App.ts
- /app
- hooks.ts - 커스텀 타입을 지정합니다.
- store.ts - 여러 가지 스토어 관련 설정을 할 수 있는 파일입니다.
- /features
- /counter
- Counter.tsx - 상태를 받아 UI를 처리하는 컴포넌트를 작성합니다.
- counterSlice.ts - Redux 로직을 작성합니다.
- /counter
- /app
Redux Store
configureStroe 메서드를 통해서 Redux 스토어를 만들게 됩니다.
reducer 속성에 전달하고자 하는 리듀서를 넣어주면 됩니다.
const stroe = configureStore({
reducer: {
counter: counterReducer,
},
});
상태에 접근할 때는 state.counter 형태로 접근이 가능합니다.
또한 액션이 디스 패치하게 된다면 해당 리듀서로 상태와 액션이 전달하게 되고,
해당 리듀서는 상태를 업데이트할지 여부를 판단합니다.
내부적으로 좋은 개발자 경험을 제공하기 위해서 여러 미들웨어가 기본적으로 설정되어있습니다.
대표적으로 Redux DevTools Extension라고 불리는 개발자 도구가 있는데,
해당 도구를 통해서 상태 추적을 쉽게 가능합니다.
좀 더 깔끔하게 전달하고 싶다면 combineReducres 메서드에 각 리듀서를 전달하고,
반환되는 값을 전달하게 되면 됩니다.
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer,
comments: commentsReducer
});
const store = configureStore({
reducer: rootReducer
});
Getting State type
루트 리듀서에서 관리하는 상태의 타입을 갖고 오고 싶다면 다음과 같이 타입을 지정합니다.
export type RootState = ReturnType<typeof store.getState>;
다음과 같이 타입을 명시하여 추론이 가능합니다.
const counter = useSelector((state: RootState) => state.counter);
Getting Dispatch type
해당 작업은 루트 리듀서에 등록된 액션 생성자 타입을 쉽게 추론하게 해주는 작업입니다.
export type AppDispatch = typeof store.dispatch;
다음과 같이 타입을 명시하여 추론이 가능합니다.
const dispatch: AppDispatch = useDispatch()
Redux Slices
공식문서에서 Redux Slices는 다음과 같이 명시되어 있습니다.
A "slice" is a collection of Redux reducer logic and actions for a single feature in your app.
이렇듯 slice는 어떠한 작업에 대한 기능을 담당하게 됩니다.
RTK에서는 createSlice라고 불리는 함수를 사용하여 액션 타입과, 액션 생성 함수, 액션 객체를 만들어 냅니다.
import { createSlice } from "@reduxjs/toolkit";
export const counterSlice = createSlice({
name: "counter",
initialState: {
value: 0,
},
reducers: {
increment: state => {
state.value += 1;
},
decrement: state => {
state.value -= 1;
},
incrementByAmount: (state, action) => {
state.value += action.payload;
},
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
createSlice의 객체의 첫 번째 속성으로 name의 값을 전달하게 되는데,
name + 리듀서의 키 속성의 이름으로 액션 객체를 자동으로 생성하게 됩니다.
counterSlice.actions.increment(); -> {type: "counter/increment"}
객체의 두 번째 속성으로 초기값을 지정하여야 합니다.
초기값을 지정하게 된다면 내부에서 자동으로 타입을 추론하게 됩니다.
initialState: {
value: 0,
},
리듀서를 작성할 때는 중요한 규칙이 있습니다.
1. 새로운 상태 값은 항상 state와 action 매개변수를 통해서만 만들어야 합니다.
2. 상태를 변경할 때는 항상 불변 값으로 다루어야 합니다.
3. 비동기 로직이나 사이드 이펙트를 내부에 작성하면 안 됩니다.
왜 이런 규칙들을 지키면서 코드를 작성해야 할까요?
첫 번째 이유는 코드를 예측 가능하게 하기 위해서입니다. 동일한 인자 값을 넣으면 동일한 출력 값을 내는 계산 함수를 작성하게 된다면 코드가 동작하는 방식을 쉽게 이해가 가능하고, 테스트 또한 쉬워집니다.
하지만 규칙 중에 2번째 규칙에는 분명히 immutable 하게 값을 다룬다고 했는데 왜 예제에는 state에 바로 접근하여 값을 바꿀까요? 올바르게 작성하려면 다음과 같이 작성해야 하는 것이 아닌가요?
return {
...state,
value: 123
}
맞습니다. 기본적이 Reducers를 다룰 때는 위와 같이 접근하는 방법이 올바른 코드 작성법입니다.
하지만 객체의 중첩 깊이가 더 많아질수록 코드의 복잡성도 덩달아 올라간다는 것을 알고 있었나요?
왜냐하면 객체의 깊은 중첩까지 복사를 하려면 더 많은 펼침 연산자를 사용하게 되고,
그에 따른 많은 복사 비용이 발생하기 때문에 비효율적입니다.
function handwrittenReducer(state, action) {
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
}
이렇기 때문에 Redux팀은 좀 더 효율적으로 값을 복사하면서 mutates 하게 작성을 해도
내부에서는 immutable 하게 다루는 라이브러리 Immer을 기본값으로 채택하게 되었습니다.
Immer라이브러리의 간단한 동작원리를 설명하자면
Proxy라는 자바스크립트를 이용하여 사용자가 제공하는 데이터를 래핑 하는 형태로 사용이 됩니다.
그리고 사용자가 변경하려는 모든 것을 감시하고 있다가 사용자가 mutates 하게 작성하는 것을 확인하면
안전하게 immutable로 값을 바꾸게 됩니다.
Immer를 적용하게 되면 다음과 같이 작성해도 문제가 발생하지 않습니다.
function reducerWithImmer(state, action) {
state.first.second[action.someId].fourth = action.someValue
}
반드시 기억해야 하는 것은 createSlice와 createReducer 함수에만 Immer가 내부적으로 적용이 되었다는 사실입니다.
다른 로직에서는 항상 값을 immutable 하게 작성되어야 합니다.
createSlice Type
createSlice의 reducers 속성에 로직이 복잡하다면 가독성을 위해 외부로 만들어서 전달할 수 있습니다.
타입 스크립트를 사용한다면 CaseReducer <State, PayloadAction <Type>> 형태로 타입을 지정해주어야 합니다.
const increment: CaseReducer<State> = state => {
state.value += 1;
};
const decrement: CaseReducer<State> = state => {
state.value += 1;
};
const incrementByAmount: CaseReducer<State, PayloadAction<number>> = (state, action) => {
state.value += action.payload;
};
export const counterSlice = createSlice({
name: "counter",
initialState: {
value: 0,
},
reducers: {
increment,
decrement,
incrementByAmount,
},
});
초기값 같은 경우에는 명시적으로 인터페이스를 지정하여 상태 지정이 가능하며,
createSlice내부에 초기값을 바로 넣어줘도 내부에서 자동으로 추론이 가능합니다.
type SliceState = { state: 'loading' } | { state: 'finished'; data: string }
// First approach: define the initial state using that type
const initialState: SliceState = { state: 'loading' }
createSlice({
name: 'test1',
initialState, // type SliceState is inferred for the state of the slice
reducers: {},
})
// Or, cast the initial state as necessary
createSlice({
name: 'test2',
initialState: { state: 'loading' } as SliceState,
reducers: {},
})
Redux에서 비동기 처리
Redux에서 비동기 처리는 어떤 식으로 처리해야 할까요?
바로 thunk 함수를 사용하여 비동기 처리를 진행하곤 합니다.
코드의 기본적인 동작은 아래와 같습니다.
export const incrementAsync = amount => dispatch => {
setTimeout(() => {
dispatch(incrementByAmount(amount))
}, 1000)
}
일반 액션 생성 함수처럼 사용이 가능해집니다.
store.dispatch(incrementAsync(5))
하지만 실제로 사용하려고 하려면 redux-thunk라는 미들웨어가 필요하게 됩니다.
다행히도 RTK에는 기본적으로 적용이 되어있어서 해당 미들웨어는 사용이 가능합니다.
thunk의 미들웨어는 다음과 같은 형태입니다.
const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === "function") {
return action(dispatch, getState);
}
return next(action);
};
들어오는 액션 값 중에서 함수가 아닌 값은 일반적인 액션 객체라고 판단하고 next메서드에 그대로 전달을 해줍니다.
이런 식으로 동작을 하게 된다면 동기 코드와 비동기 코드 둘 다 정상적으로 동작을 보장합니다.
리액트 컴포넌트와 연동하기
React와 Redux store와 상호작용하기 위해서는 커스텀 훅이 필요하게 됩니다.
React-Redux 라이브러리를 추가로 프로젝트에 넣어줘서 해당 커스텀 훅을 활성화해보겠습니다.
useSelector
해당 훅을 사용하게 된다면 스토어 상태에서 필요한 모든 상태에 접근이 가능하게 됩니다.
일반적인 스토어에서 상태에 접근하려면 다음과 같은 로직이 필요합니다.
export const selectCount = state => state.counter.value;
selectCount(store.getState()); // 0
store.getState().counter.value // 0
커스텀 훅을 사용하게 된다면 다음과 같은 로직으로 상태에 접근이 가능합니다.
const selectCount = state => state.counter.value;
const count = useSelector(selectCount);
// 인라인 형태로 상태에 접근도 가능합니다.
const count = useSelector(state => state.counter.value);
어떠한 액션이 디스 패치하게 된다면 상태가 리덕스 스토어는 항상 최신으로 상태를 업데이트하게 됩니다.
이때 selector 함수는 리 렌더링을 진행하게 됩니다.
useDispatch
해당 훅을 사용하여 액션 디스패치를 가능하게 해 줍니다.
일반적인 디스패치는 다음과 같은 형태로 진행이 됩니다.
store.dispatch(increment());
커스텀 훅을 사용하게 된다면 다음과 같은 형태로 디스패치가 가능합니다.
function Counter() {
const dispatch = useDispatch();
return <button onClick={() => dispatch(increment())}>+</button>;
}
Define Typed Hooks
훅을 사용할 때마다 타입을 명시하지 않고,
사전에 타입을 지정하여 훅을 사용하면 일일이 타입을 지정하는 로직을 작성하지 않아도 됩니다.
해당 유형은 타입이 아닌 실제 변수이므로, store 파일이 아닌 hook.ts라는 파일로 분리하여 변수로 지정해야 합니다.
이렇게 하면 모든 구성 요소를 쉽게 가져올 수 있으며 순환 종속성 이슈를 방지할 수 있습니다.
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import { AppDispatch, RootState } from "./stroe";
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Providing the Store
마지막으로 스토어와 상태를 통신하게 하려면 다음과 같은 작업이 필요합니다.
우선 Provider라는 컴포넌트를 루트 App 컴포넌트 주위에 감싸야합니다.
그다음 store 속성에 스토어를 전달해주어야 합니다.
<Provider store={store}>
<App />
</Provider>
위와 같은 작업을 진행하게 된다면 useSelector, useDispatch 같은 훅을 통해서
스토어의 상태를 올바르게 처리가 가능합니다.
최종적으로 다음과 같은 형태로 글로벌 상태 관리가 가능하고 액션 또한 디스패치가 가능합니다.
function Counter() {
const dispatch = useAppDispatch();
const counte = useAppSelector(state => state.counter.value);
return (
<div>
<h2>{counte}</h2>
<button onClick={() => dispatch(increment())}>+</button>
</div>
);
}
다음 파트에서는 Redux에서 비동기 처리하는 방법에 대해서 포스팅하겠습니다.
정리
- configureStore API를 통해서 Redux 스토어를 만들어낸다.
- redux 로직은 slices라고 불리는 파일로 구성된다.
- redux의 reducrs는 항상 불변한 값으로 다뤄야 하고 비동기 로직, 부수효과를 넣어서는 안 된다.
- createSlice API는 내부적으로 Immer 라이브러리를 사용하여 불변 값을 다룬다.
- 비동기 로직은 thunks라고 불리는 함수로 다뤄진다.
- React-Redux를 사용하여 Redux 스토어와 상호작용이 이뤄진다.
이때 반드시 Provider 컴포넌트에 속성으로 store를 전달해야 한다.
- 전역 상태는 리덕스 스토어로 관리하고 로컬 상태는 자체 컴포넌트 내에서 처리해야 한다.
'기술' 카테고리의 다른 글
아니 React 공부하면 Redux는 필수 공부 아닌가요? (part 1) (0) | 2022.08.19 |
---|