redux-toolkit 공식 문서 사용 가이드를 개인 학습용으로 정리한 글입니다.
Redux Toolkit의 목표는 일반적인 Redux 사용 사례를 단순화하는 것입니다. Redux Toolkit이 Redux 관련 코드를 개선하는데 도움이 되는 몇 가지 방법을 살펴 보겠습니다.
모든 Redux 앱은 Redux 스토어를 구성하고 생성해야 합니다. 여기에는 일반적으로 여러 단계가 포함됩니다.
import { applyMiddleware, createStore } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension'
import thunkMiddleware from 'redux-thunk'
import monitorReducersEnhancer from './enhancers/monitorReducers'
import loggerMiddleware from './middleware/logger'
import rootReducer from './reducers'
export default function configureStore(preloadedState) {
const middlewares = [loggerMiddleware, thunkMiddleware]
const middlewareEnhancer = applyMiddleware(...middlewares)
const enhancers = [middlewareEnhancer, monitorReducersEnhancer]
const composedEnhancers = composeWithDevTools(...enhancers)
const store = createStore(rootReducer, preloadedState, composedEnhancers)
if (process.env.NODE_ENV !== 'production' && module.hot) {
module.hot.accept('./reducers', () => store.replaceReducer(rootReducer))
}
return store
}
이 예제는 읽을 수 있지만 프로세스가 항상 간단하지는 않습니다.
configureStore
를 통한 스토어 설정 단순화configureStore
는 다음을 통해 이러한 문제를 돕습니다.
applicationMiddleware
및 compose
호출 가능또한 configureStore
는 기본적으로 다음과 같은 목표를 가진 일부 미들웨어를 추가합니다.
이것은 스토어 설정 코드 자체가 조금 더 짧고 읽기 쉬우며 기본적으로 좋은 동작을 얻을 수 있음을 의미합니다.
이를 사용하는 가장 간단한 방법은 rootReducer 함수를 reducer라는 파라미터로 전달하는 것입니다.
import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'
const store = configureStore({
reducer: rootReducer,
})
export default store
또한 "slice reducers"로 가득 찬 객체를 전달할 수 있으며 configureStore는 다음과 같이 combineReducers를 호출합니다.
import { configureStore } from '@reduxjs/toolkit'
import usersReducer from './usersReducer'
import postsReducer from './postsReducer'
const store = configureStore({
reducer: {
users: usersReducer,
posts: postsReducer,
},
})
export default store
리듀서는 가장 중요한 Redux 개념입니다. 일반적인 리듀서 함수는 다음을 수행해야합니다.
type
의 필드를 보고 어떻게 반응해야하는지 확인리듀서에서 원하는 조건문을 사용할 수 있지만 가장 일반적인 접근 방식은 switch
명령문입니다. 단일 필드에 대해 가능한 여러 값을 처리하는 간단한 방법이기 때문입니다. 그러나 많은 사람들이 switch
문을 좋아하지 않습니다.
리듀서 작성과 관련된 다른 일반적인 문제점은 상태를 변경없이 업데이트하는 것과 관련이 있습니다. 자바 스크립트는 변경 가능한 언어이므로 중첩된 변경 불가능한 데이터를 수동으로 업데이트하는 것은 어렵고 실수하기 쉽습니다.
리덕스 툴킷은 리덕스 문서에 나와 있는 것과 유사한 createReducer
기능을 포함하고 있습니다. 그러나 createReducer
유틸리티에는 더 나은 마법이 있습니다. 내부적으로 Immer 라이브러리를 사용하여 일부 데이터를 "변경"하는 코드를 작성할 수 있지만 실제로는 업데이트를 변경할 수 없습니다. 이것은 reducer에서 실수로 상태를 변형시키는 것을 사실상 불가능하게 만듭니다.
일반적으로 스위치 문을 사용하는 모든 리듀서는 createReducer
를 직접 사용하도록 변환할 수 있습니다. 스위치의 각 케이스는 createReducer
로 전달된 객체의 키가 됩니다. 객체를 펼치거나 배열을 복사하는 것과 같은 불변의 업데이트 로직은 아마도 직접적인 "mutation"으로 변환될 수 있을 것입니다. 변경할 수 없는 업데이트를 있는 그대로 유지하고 업데이트된 복사본을 반환하는 것도 괜찮습니다.
다음은 createReducer
를 사용할 수 있는 방법의 몇 가지 예입니다.
function todosReducer(state = [], action) {
switch (action.type) {
case 'ADD_TODO': {
return state.concat(action.payload)
}
case 'TOGGLE_TODO': {
const { index } = action.payload
return state.map((todo, i) => {
if (i !== index) return todo
return {
...todo,
completed: !todo.completed,
}
})
}
case 'REMOVE_TODO': {
return state.filter((todo, i) => i !== action.payload.index)
}
default:
return state
}
}
state.concat()를 호출하여 새 todo 항목과 함께 복사된 배열을 반환하고 state.map()을 호출하여 토글 케이스에 대해 복사된 배열을 반환하고 객체 분산 연산자를 사용하여 업데이트할 작업 내용의 복사본을 만듭니다.
createReducer를 사용하면 다음과 같은 예를 상당히 줄일 수 있습니다.
const todosReducer = createReducer([], (builder) => {
builder
.addCase('ADD_TODO', (state, action) => {
// "mutate" the array by calling push()
state.push(action.payload)
})
.addCase('TOGGLE_TODO', (state, action) => {
const todo = state[action.payload.index]
// "mutate" the object by overwriting a field
todo.completed = !todo.completed
})
.addCase('REMOVE_TODO', (state, action) => {
// Can still return an immutably-updated value if we want to
return state.filter((todo, i) => i !== action.payload.index)
})
})
상태를 "변경"하는 기능은 깊이 중첩된 상태를 업데이트하려고 할 때 특히 유용합니다.
복잡하고 고통스러운 코드:
case "UPDATE_VALUE":
return {
...state,
first: {
...state.first,
second: {
...state.first.second,
[action.someId]: {
...state.first.second[action.someId],
fourth: action.someValue
}
}
}
}
다음과 같이 단순화할 수 있습니다.
updateValue(state, action) {
const {someId, someValue} = action.payload;
state.first.second[someId].fourth = someValue;
}
Redux는 액션 객체를 생성하는 과정을 캡슐화하는 "액션 생성자"함수를 작성 하도록 권장 합니다. 이것이 꼭 필요한 것은 아니지만 Redux 사용의 표준 부분입니다.
대부분의 액션 생성자는 매우 간단합니다. 일부 매개 변수를 취하고, 특정 type
필드와 액션 내부의 매개 변수가 있는 액션 오브젝트를 리턴합니다. 이러한 매개 변수는 일반적으로 액션 객체의 내용을 구성하기 위한 Flux Standard Action 규칙을 따르는 payload
라 불리는 필드에 배치됩니다. 일반적인 액션 생성자는 다음과 같습니다.
function addTodo(text) {
return {
type: 'ADD_TODO',
payload: { text },
}
}
액션 제작자를 손으로 쓰는 것은 지루할 수 있습니다. Redux Toolkit은 주어진 액션 타입을 사용하는 액션 생성자를 생성하고 인수를 payload
필드로 변환하는 createAction
이라는 함수를 제공합니다.
const addTodo = createAction('ADD_TODO')
addTodo({ text: 'Buy milk' })
// {type : "ADD_TODO", payload : {text : "Buy milk"}})
createAction
은 또한 결과 payload
필드를 사용자 정의하고, 선택적으로 meta
필드를 추가 할 수 있는 "콜백 준비" 인수를 허용합니다. 콜백 준비로 액션 생성자를 정의하는 방법에 대한 자세한 내용은 createActionAPI 참조 를 참조하세요.
Redux 상태는 일반적으로 "slices"로 구성되며, combineReducers
에 전달되는 리듀서에 의해 정의됩니다.
import { combineReducers } from 'redux'
import usersReducer from './usersReducer'
import postsReducer from './postsReducer'
const rootReducer = combineReducers({
users: usersReducer,
posts: postsReducer,
})
이 예에서 users
및 posts
둘 다 "슬라이스"로 간주됩니다. 두 리듀서 :
일반적인 접근 방식은 자체 파일에 슬라이스의 리듀서 함수를 정의하고, 두 번째 파일에 액션 생성자를 정의하는 것입니다. 두 함수 모두 동일한 액션 유형을 참조해야하기 때문에 일반적으로 세 번째 파일에 정의되고 두 위치에서 가져옵니다.
// postsConstants.js
const CREATE_POST = 'CREATE_POST'
const UPDATE_POST = 'UPDATE_POST'
const DELETE_POST = 'DELETE_POST'
// postsActions.js
import { CREATE_POST, UPDATE_POST, DELETE_POST } from './postConstants'
export function addPost(id, title) {
return {
type: CREATE_POST,
payload: { id, title },
}
}
// postsReducer.js
import { CREATE_POST, UPDATE_POST, DELETE_POST } from './postConstants'
const initialState = []
export default function postsReducer(state = initialState, action) {
switch (action.type) {
case CREATE_POST: {
// omit implementation
}
default:
return state
}
}
우리가 여러 파일을 작성하는 유일한 이유는 코드를 수행하는 작업에 따라 코드를 분리하는 것이 일반적이기 때문입니다.
"Ducks" 파일 구조는 주어진 슬라이스에 대한 모든 리덕스 관련 로직을 다음과 같이 단일 파일에 넣을 것을 제안합니다.
// postsDuck.js
const CREATE_POST = 'CREATE_POST'
const UPDATE_POST = 'UPDATE_POST'
const DELETE_POST = 'DELETE_POST'
export function addPost(id, title) {
return {
type: CREATE_POST,
payload: { id, title },
}
}
const initialState = []
export default function postsReducer(state = initialState, action) {
switch (action.type) {
case CREATE_POST: {
// Omit actual code
break
}
default:
return state
}
}
이렇게 하면 여러 개의 파일이 필요하지 않고 작업 유형 상수의 중복 가져오기를 제거할 수 있으므로 작업이 간소화됩니다. 하지만 우리는 여전히 액션 종류와 액션 크리에이터를 손으로 작성해야 합니다.
이 프로세스를 단순화하기 위해 Redux Toolkit에는 사용자가 제공하는 리듀서 함수의 이름에 따라 액션 타입 및 액션 생성자를 자동 생성 하는 createSlice
함수가 포함되어 있습니다.
다음은 게시물 예제가 createSlice
를 사용한 방식입니다.
const postsSlice = createSlice({
name: 'posts',
initialState: [],
reducers: {
createPost(state, action) {},
updatePost(state, action) {},
deletePost(state, action) {},
},
})
console.log(postsSlice)
/*
{
name: 'posts',
actions : {
createPost,
updatePost,
deletePost,
},
reducer
}
*/
const { createPost } = postsSlice.actions
console.log(createPost({ id: 123, title: 'Hello World' }))
// {type : "posts/createPost", payload : {id : 123, title : "Hello World"}}
createSlice
는 reducers
필드에 정의된 모든 함수를 살펴보고, 제공된 모든 "케이스 리듀서" 함수에 대해 리듀서의 이름을 액션 타입 자체로 사용하는 액션 생성자를 생성합니다.
그래서, createPost
리듀서는 "posts/createPost"
액션 타입이 되었고, createPost()
액션 생성자는 해당 타입과 액션을 반환합니다.
대부분의 경우 슬라이스를 정의하고 액션 생성자와 리듀서를 내보내고 싶을 것입니다. 이를 위해 권장되는 방법은 ES6 구조 분해 및 내보내기 구문을 사용하는 것입니다.
const postsSlice = createSlice({
name: 'posts',
initialState: [],
reducers: {
createPost(state, action) {},
updatePost(state, action) {},
deletePost(state, action) {},
},
})
// Extract the action creators object and the reducer
// 액션 생성자 객체와 리듀서 추출
const { actions, reducer } = postsSlice
// Extract and export each action creator by name
// 각 액션 생성자를 이름으로 추출 및 내보내기
export const { createPost, updatePost, deletePost } = actions
// Export the reducer, either as a default or named export
export default reducer
원하는 경우 슬라이스 객체 자체를 직접 내보낼 수도 있습니다.
이렇게 정의 된 슬라이스는 액션 생성자와 리듀서를 정의하고 내보내는 "Redux Ducks"패턴과 개념적으로 매우 유사 합니다. 그러나 슬라이스를 가져오고 내보낼 때 유의해야 할 몇 가지 잠재적인 단점이 있습니다.
첫째, Redux 액션 타입은 단일 슬라이스에만 국한되지 않습니다.
개념적으로 각 슬라이스 리듀서는 Redux 상태의 자체 부분을 "소유"하지만 모든 액션 타입을 수신하고 상태를 적절하게 업데이트 할 수 있어야 합니다. 예를 들어 여러 슬라이스에서 데이터를 지우거나 초기 상태 값으로 다시 설정하여 "사용자 로그아웃" 액션에 응답하려고 할 수 있습니다. 상태 모양을 디자인하고 슬라이스를 만들 때 염두에 두십시오.
둘째, JS 모듈은 두 모듈이 서로를 가져 오려고 하면 "순환 참조"문제가 발생할 수 있습니다. 이로 인해 import가 정의되지 않아 해당 import가 필요한 코드가 손상될 수 있습니다. 특히 "ducks" 또는 슬라이스의 경우 두 개의 서로 다른 파일에 정의된 슬라이스가 모두 다른 파일에 정의된 작업에 응답하려는 경우 발생할 수 있습니다.
이 문제가 발생하면 순환 참조를 피하는 방식으로 코드를 재구성해야 할 수 있습니다. 일반적으로 두 모듈 모두 가져 와서 사용할 수 있는 별도의 공통 파일로 공유 코드를 추출해야합니다. 이 경우 createAction
을 사용하여 별도의 파일에 몇 가지 공통 액션 타입을 정의하고, 해당 액션 생성자를 각 슬라이스 파일로 가져온 다음 extraReducers
인수를 사용하여 처리 할 수 있습니다.
JS에서 순환 종속성 문제를 해결하는 방법 문서 에는 이 문제에 도움이 될 수있는 추가 정보와 예제가 있습니다.
그 자체로 Redux 스토어는 비동기 로직에 대해 아무것도 모릅니다. 동기적으로 작업을 전달하고 루트 리듀서 함수를 호출하여 상태를 업데이트하고 UI에 변경 사항을 알리는 방법만 알고 있습니다. 모든 비동기성은 스토어 외부에서 발생해야 합니다.
그러나 현재 스토어 상태를 디스패치하거나 확인하여 비동기 로직이 스토어와 상호 작용하게 하려면 어떻게 해야 할까요? 이런 상황이 바로 Redux 미들웨어가 들어오는 곳입니다. 리덕스 미들웨어는 스토어를 확장하고 다음을 가능하게 합니다.
dispatch
와 getState
에 액세스 할 수 있는 추가 코드 작성미들웨어를 사용하는 가장 일반적인 이유는 여러 종류의 비동기 논리가 저장소와 상호 작용할 수 있도록하는 것 입니다. 이를 통해 작업을 전달하고 저장소 상태를 확인할 수있는 코드를 작성할 수 있으며 해당 논리를 UI와 분리 할 수 있습니다.
Redux에는 여러 종류의 비동기 미들웨어가 있으며 각 미들웨어는 서로 다른 구문을 사용하여 로직을 작성할 수 있습니다. 가장 일반적인 비동기 미들웨어는 다음과 같습니다.
redux-thunk
, 비동기 로직을 직접 포함 할 수 있는 일반 함수를 작성할 수 있습니다.redux-saga
, 미들웨어에서 실행할 수 있도록 동작 설명을 반환하는 제너레이터 함수를 사용합니다.redux-observable
, RxJS 관찰 가능 라이브러리를 사용하여 액션을 처리하는 함수 체인을 만듭니다.이러한 각 라이브러리에는 서로 다른 사용 사례와 장단점이 있습니다.
Redux Thunk 미들웨어를 표준 접근 방식으로 사용하는 것이 좋습니다. 대부분의 일반적인 사용 사례 (예 : 기본 AJAX 데이터 가져 오기)에 충분하기 때문입니다. 또한 thunk에서 async/await
구문을 사용하면 쉽게 읽을 수 있습니다.
Redux Toolkit configureStore
함수는 기본적으로 thunk 미들웨어를 자동으로 설정하므로 애플리케이션 코드의 일부로 thunk 작성을 즉시 시작할 수 있습니다.
Redux Toolkit은 현재 thunk 함수 작성을 위한 특수 API 또는 구문을 제공하지 않습니다. 특히 createSlice() 호출의 일부로 정의할 수 없습니다. 일반 Redux 코드와 똑같이 리듀서 로직과 별도로 작성해야합니다.
썽크는 일반적으로 dispatch(dataLoaded(response.data))
와 같은 방식으로 액션을 디스패치 합니다.
많은 Redux 앱은 "폴더 별"접근 방식을 사용하여 코드를 구성했습니다. 이 구조에서 썽크 액션 생성자는 보통 일반 액션 생성자와 함께 "액션" 파일에 정의됩니다.
별도의 "액션" 파일이 없기 때문에 이러한 썽크를 "슬라이스"파일에 직접 작성하는 것이 좋습니다. 이렇게 하면 슬라이스에서 일반 액션 생성자에 액세스 할 수 있으며 썽크 함수가 있는 위치를 쉽게 찾을 수 있습니다.
썽크를 포함하는 일반적인 슬라이스 파일은 다음과 같습니다.
// First, define the reducer and action creators via `createSlice`
const usersSlice = createSlice({
name: 'users',
initialState: {
loading: 'idle',
users: [],
},
reducers: {
usersLoading(state, action) {
// Use a "state machine" approach for loading state instead of booleans
// boolean 대신 로딩 상태에 접근하는 state machine 사용 => why..?
if (state.loading === 'idle') {
state.loading = 'pending'
}
},
usersReceived(state, action) {
if (state.loading === 'pending') {
state.loading = 'idle'
state.users = action.payload
}
},
},
})
// Destructure and export the plain action creators
export const { usersLoading, usersReceived } = usersSlice.actions
// Define a thunk that dispatches those action creators
// 액션 생성자를 내보내는 썽크 정의
const fetchUsers = () => async (dispatch) => {
dispatch(usersLoading())
const response = await usersAPI.fetchAll()
dispatch(usersReceived(response.data))
}
Redux의 데이터 가져오기 로직은 일반적으로 예측 가능한 패턴을 따릅니다.
이 단계는 필수는 아니지만 Redux 자습서에서 제안된 패턴으로 권장됩니다.
일반적인 구현은 다음과 같습니다.
const getRepoDetailsStarted = () => ({
type: "repoDetails/fetchStarted"
})
const getRepoDetailsSuccess = (repoDetails) => {
type: "repoDetails/fetchSucceeded",
payload: repoDetails
}
const getRepoDetailsFailed = (error) => {
type: "repoDetails/fetchFailed",
error
}
const fetchIssuesCount = (org, repo) => async dispatch => {
dispatch(getRepoDetailsStarted())
try {
const repoDetails = await getRepoDetails(org, repo)
dispatch(getRepoDetailsSuccess(repoDetails))
} catch (err) {
dispatch(getRepoDetailsFailed(err.toString()))
}
}
그러나 이 방법을 사용하여 코드를 작성하는 것은 지루합니다. 각각의 개별 요청 유형에는 반복되는 유사한 구현이 필요합니다.
createAsyncThunk
은 액션 타입 및 액션 생성자를 생성하고 해당 액션을 전달하는 썽크를 생성하여 이 패턴을 추상화합니다.
개발자는 API 요청에 필요한 실제 로직, Redux 작업 기록 로그에 표시되는 액션 타입 이름, 리듀서가 가져온 데이터를 처리하는 방법에 가장 관심이 있을 것입니다. 여러 액션 타입을 정의하고 올바른 순서로 작업을 전달하는 반복적인 세부 사항은 중요하지 않습니다.
createAsyncThunk
은 이 프로세스를 단순화합니다. 액션 타입 접두사에 대한 문자열과 실제 비동기 로직을 수행하고 결과와 함께 promise를 반환하는 페이로드 생성자 콜백만 제공하면 됩니다.
그 대가로 createAsyncThunk
는 반환하는 프로미스에 따라 올바른 액션을 처리하는 썽크와, 리듀서에서 처리 할 수 있는 액션 타입을 제공합니다.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
// First, create the thunk 썽크 생성
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
}
)
// Then, handle actions in your reducers:
const usersSlice = createSlice({
name: 'users',
initialState: { entities: [], loading: 'idle' },
reducers: {
// standard reducer logic, with auto-generated action types per reducer 일단 리듀서 로직, 자동 생성된 액션 타입 포함
},
extraReducers: {
// Add reducers for additional action types here, and handle loading state as needed 추가 액션 타입에 대한 리듀서 추가, 필요에 따라 로드 상태 처리
[fetchUserById.fulfilled]: (state, action) => {
// Add user to the state array
state.entities.push(action.payload)
},
},
})
// Later, dispatch the thunk as needed in the app
dispatch(fetchUserById(123))
썽크 액션 생성자는 페이로드 생성자 콜백에 첫 번째 인수로 전달되는 단일 인수를 받습니다.
페이로드 생성자는 일반적으로 표준 Redux 썽크 함수에 전달되는 매개 변수가 포함된 thunkAPI
객체와 자동 생성된 고유한 임의 요청 ID 문자열 및 AbortController.signal
객체도 수신합니다.
interface ThunkAPI {
dispatch: Function
getState: Function
extra?: any
requestId: string
signal: AbortSignal
}
페이로드 콜백 내에서 필요에 따라 이들 중 하나를 사용하여 최종 결과가 무엇인지 결정할 수 있습니다.
대부분의 애플리케이션은 일반적으로 깊이 중첩되거나 관계형 데이터를 처리합니다. 데이터 정규화의 목표는 해당 데이터를 효율적으로 구성하는 것입니다. 이것은 일반적으로 컬렉션을 id
키가 있는 객체로 저장하고, 정렬된 배열을 ids
로 저장하여 수행됩니다. 보다 심층적인 설명과 추가 예제는 "Normalizing State Shape" 에 대한 Redux 문서 페이지에 훌륭한 참조가 있습니다.
데이터 정규화에는 특별한 라이브러리가 필요하지 않습니다. 다음은 손으로 작성한 로직을 사용하여 fetchAll
형식으로 데이터를 반환 하는 API 요청의 응답을 정규화하는 방법에 대한 기본 예입니다
{ users: [{id: 1, first_name: 'normalized', last_name: 'person'}] }
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import userAPI from './userAPI'
export const fetchUsers = createAsyncThunk('users/fetchAll', async () => {
const response = await userAPI.fetchAll()
return response.data
})
export const slice = createSlice({
name: 'users',
initialState: {
ids: [],
entities: {},
},
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchUsers.fulfilled, (state, action) => {
// reduce the collection by the id property into a shape of { 1: { ...user }}
const byId = action.payload.users.reduce((byId, user) => {
byId[user.id] = user
return byId
}, {})
state.entities = byId
state.ids = Object.keys(byId)
})
},
})
이 코드를 작성할 수는 있지만 특히 여러 유형의 데이터를 처리하는 경우 반복적이 됩니다. 또한이 예제는 항목을 업데이트하지 않고 상태로 로드하는 항목만 처리합니다.