계기
Redux를 공부하면서 Redux ToolKit을 이어서 공부하게 된다.
Redux의 개념 자체에 대한 것은 Redux 공부하기를 참고하면 되겠다.
Redux Toolkit이란 무엇인가
Redux Toolkit is our recommended approach for writing Redux logic.
Redux Toolkit이란 Redux 로직을 작성하는데 있어 Redux측에서 공식적으로 권장하는 방식이다.
기본적으로 이를 기반으로 Redux 로직을 작성하게 된다.
Redux Store 만들기
// File: "store.ts"
import type { Action } from '@reduxjs/toolkit'
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '@/features/counter/counterSlice'
export const store = configureStore({
reducer: {
counter: counterReducer
}
})
// Infer the type of `store`
export type AppStore = typeof store // Redux에서 관리하는 store의 타입
export type RootState = ReturnType<AppStore['getState']> // store가 관리하는 state의 타입
// Infer the `AppDispatch` type from the store itself
export type AppDispatch = AppStore['dispatch'] // store에서 dispatch 함수의 타입
// Define a reusable type describing thunk functions
Redux의 Store는 configureStore라는 함수를 Redux Toolkit에서 꺼내, 생성함으로써 만들어진다.
configureStore함수는 reducer를 인자로 전달받는데, 필요한 Reducer가 여러개라면,
key 값: Reducer꼴의 여러 개를 하나의 객체로 넣어주면 된다.
이렇게 넣으면, 키 값을 통해 Store에서 관리하는 키 값에 해당하는 Reducer가 관리하는 상태의 해당 영역에 대해서 접근할 수 있다.
즉 본 예시에서는 counter를 키 값으로 하는 counterReducer를 넣어주었는데, getState함수를 통해 상태를 얻을 수 있을 때, getState().counter를 통해 counterReducer가 관리하는 상태에 접근할 수 있다.
Redux Slice
본 위 코드에서 import 문을 보면 counterReducer를 counterSlice라는 곳에서 가져온 것을 알 수 있을 것이다.
Slice란 Reducer 로직과 action들의 모음으로, State, Reducer, Action의 모음이라고 할 수 있다.
이들을 하나로 묶어서 State 따로, Reducer 따로, Action 따로 따로 놀던 것을 Slice라는 하나의 객체 내에 넣어 관리하는 것이다.
createSlice 함수를 통해 name, initialState, reducers 속성을 전달받아 하나의 Slice를 생성한다.
- name : 다른 슬라이스들과 구분하기 위한 이름이다, 추후
action.type의domain이 된다. - initialState : 관리될 상태의 초기값이다.
- reducers :
action마다 state를 계산할 로직들이 정의된 객체이다.
// File: "counterSlice.ts"
import { createSlice } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
// Define the TS type for the counter slice's state
export interface CounterState {
value: number
status: 'idle' | 'loading' | 'failed'
}
// Define the initial value for the slice state
const initialState: CounterState = {
value: 0,
status: 'idle'
}
// Slices contain Redux reducer logic for updating state, and
// generate actions that can be dispatched to trigger those updates.
export const counterSlice = createSlice({
name: 'counter',
initialState,
// The `reducers` field lets us define reducers and generate associated actions
reducers: {
increment: state => {
// Redux Toolkit allows us to write "mutating" logic in reducers. It
// doesn't actually mutate the state because it uses the Immer library,
// which detects changes to a "draft state" and produces a brand new
// immutable state based off those changes
state.value += 1
},
decrement: state => {
state.value -= 1
},
// Use the PayloadAction type to declare the contents of `action.payload`
incrementByAmount: (state, action: PayloadAction<number>) => {
state.value += action.payload
}
}
})
// Export the generated action creators for use in components
// 생성된 action들
export const { increment, decrement, incrementByAmount } = counterSlice.actions
// Export the slice reducer for use in the store configuration
export default counterSlice.reducer
본 코드를 보면 위화감이 들지 않는가?
기존 Redux를 공부하며 Reducer 로직을 작성할 때 action.type에 따라 state의 변화를 처리했는데, Redux의 action은 domain/eventName꼴이지만, slice 내부에서는 action을 따로 구현해 준 것이 없는 것이 보일 것이다.
답은 name에 있다. Redux의 Slice가 createSlice를 통해 만들어질 때, name속성의 값이 자동적으로 domain으로 되어 만들어지는 것이다.
즉 위 예시에서 counter라는 name 속성과 increment라는 reducer 함수가 결합되어 counter/increment라는 action.type의 action들이 생성된 Slice 객체.actions 내에 내부적으로 알아서 생성되는 것이다.
그리고 Store객체에는 dispatch함수가 있으므로, dispatch함수의 인자로 Slice에서 export한 Action을 넣어주어, Reducer가 동작하도록 하는 것이다.
또한 이전 Redux 글을 보면 state는 Immutable 해야 한다고 적혀 있지만, 본 코드에서는 state.value에 직접적으로 값을 증감하고 있다. 이는 createSlice는 Immer라는 라이브러리를 내부에서 사용하며, 이 Immer 라이브러리가 안전하게 Immutable한 업데이트가 일어나도록 만들기 때문에 저렇게 업데이트를 해주더라도 Immutable하게 업데이트가 된다.
단 createSlice와 createReducer 내부에서만 Immer를 사용하기 때문에,
외부에서 저렇게 코드를 작성하여 state를 변경하는 작업은 금물이다
또한 incrementByAmount를 보면 action의 타입이 PayloadAction<number>로 되어 있는 것을 볼 수 있을 것이다. 이 또한 전 Redux글에서 action의 경우 type과 추가적인 데이터가 필요할 경우 관례적으로 payload라는 속성을 사용한다는 것을 언급하였는데,
이러한 payload 속성을 사용하는 action 객체라는 것을 나타내며 payload 데이터의 타입을 <타입>으로 알려준다.
The patterns and tools provided by Redux make it easier to understand when, where, why, and how the state in your application is being updated, and how your application logic will behave when those changes occur.
결론적으로 상태의 관리를 원활하게 하여 상태가 언제, 어디서, 왜 바뀌는지를 알기 쉽게 하며,
변경이 발생될 때 프로그램이 어떻게 돌아가는지를 파악하기 쉽게 한다.
Redux Selector
Redux의 selector를 만들기 위해서는 상태를 받아 상태에서 원하는 값을 반환하는 함수를 작성해야 한다.
하지만 TypeScript를 사용할 경우, 이 state가 무슨 Type인지를 알려줘야 하기 때문에 Store객체를 생성했을 때 export type RootState = ReturnType<AppStore['getState']> 꼴로 해서 Store에서 state의 타입을 import할 수 있도록 해야 하고 이를 사용한다.
// Store에서 RootState라는 타입을 가져와서 이를 인자로 받는다는 것을 명시해준다.
export const selectCount = (state: RootState) => state.counter.value
export const selectStatus = (state: RootState) => state.counter.status
// 그리고 아래와 같이 사용할 수 있다
// getState의 타입을 RootState라는 타입으로 export 했던 것을 바탕으로
// selector에서 RootState를 기반으로 원하는 값을 출력하는 로직을 작성했고 이를 실행한다.
const currentValue = selectCount(getState())
하지만 이렇게 만들어준 selector도 결국 매번 결과를 얻고 싶을 때마다 selector(getState())를 실행시켜서 결과값을 받아와야 하는 불편함이 있다.
이를 useSelector로 해결한다.
useSelector에 기존의 selector 함수처럼 state를 받아서 원하는 값을 반환하는 함수를 인자로 전달한다.
이를 통해 useSelector에서 반환되는 값은 현재 state를 기준으로 인자로 넣었던 함수가 돌아간 최신 값을 반환한다.
export const selectCount = (state: RootState) => state.counter.value
// 이렇게 기존에 정의한 Store 객체의 state를 받아 정의한 함수를 넣을 수도 있고,
const count = useSelector(selectCount)
// 이렇게 직접 함수를 작성한 것을 넣어서 만들 수도 있다.
const countPlusTwo = useSelector((state: RootState) => state.counter.value + 2)
Redux Dispatch
기본적으로 Store 객체에 dispatch라는 함수가 있고 여기에 이벤트를 인자로 넣어 state의 변화를 일으키지만, useDispatch를 통해 꼭 Store객체.dispatch가 아니더라도, dispatch함수를 불러와 사용할 수 있다.
const dispatch = useDispatch()
Pre-Typed Hooks
기존에 useSelector코드들을 보면 useSelector의 인자로 만드는 함수의 State인자의 타입을 항상 지정해주었다. 뿐만 아니라 dispatch 또한 Redux Thunk같은 것을 통해 사용하게 될 경우 Type이 명시해야 될 수 있는데, 이 또한 번거로운 작업이 될 것이다.
이를 withTypes로 미리 설정하여 해결할 수 있다.
import { useDispatch, useSelector } from 'react-redux';
import type { AppDispatch, RootState } from './store';
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
위와 같이 설정한다면 Selector의 경우에는 이미 RootState임을 명시해줬으므로 useAppSelector를 사용하면
import { useAppSelector, useAppDispatch } from './app/hooks';
const MyComponent = () => {
const value = useAppSelector((state) => state.someValue); // RootState 생략 가능
const dispatch = useAppDispatch(); // AppDispatch 생략 가능
};
와 같이 state의 타입을 명시하지 않고도 사용할 수 있다.
주의할 점
모든 상태를 Redux Store 내에 담아도 되는가?
NO
오직 전역에서 관리되어야 하는 상태만이Redux Store에 존재해야 한다.
오직 한 곳에서만 사용되어지는 상태는Component내부의 상태로서 존재해야 한다.
만약 한 곳에서만 사용되는 상태라면Redux보다 그저useState등을 사용하여 관리해야 한다.In a React + Redux app, your global state should go in the Redux store,
and your local state should stay in React components.판단 기준
- 애플리케이션의 다른 부분에서도 이 데이터를 필요로 하는지?
- 이 원본 데이터를 기반으로 추가적인 파생 데이터를 생성해야 하는지?
- 같은 데이터가 여러 컴포넌트에서 사용되는지?
- 이 상태를 특정 시점으로 복원할 수 있는 기능이 필요한지?
- 데이터를 캐싱하고 싶은지?
- UI를 핫 리로드 할 때도 데이터를 유지하고 싶은지?
여기에 부합하는 것이 있다면 Redux 아니라면 Component 내의 Local State로서 관리하는 것이 좋을 것이다.