Redux Toolkit 패키지는 redux 로직을 쓰기 위한 표준 방법으로서 고안되었다. 이는 Redux 의 3가지 일반적인 문제를 해결하기 위해 만들어졌다.
Redux Toolkit 은 RTK Query 라 부르는 강력한 데이터 가져오기 및 캐싱기능을 갖고 있다.
npm i @reduxjs/toolkit
단순화된 구성 옵션과 좋은 기본값을 제공하기 위해 createStore
를 래핑한다. 이 API 는 자동으로 slice reducer 를 결합하고 제공하는 Redux 미들웨어를 추가하고, redux-thunk 를 기본으로 포함하며 Redux DevTools Extention 을 사용 가능하다. redux store 를 만드는 표준 메서드이다.
import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'
const store = configureStore({ reducer: rootReducer })
// The store now has redux-thunk added and the Redux DevTools Extension is turned on
혹은 reducer 가 여러개 있을때 객체로 묶어줄 수 있다.
const store = configureStore({ reducer: {
users: usersReducer,
posts: postsReducer
} })
스위치문 작성 대신 Case Reducer 함수에 액션 타입의 lookup table 을 제공할 수 있다. 추가로 자동으로 Immer 라이브러리를 사용하여 state.todos[3].completed = true
같은 immutable update 를 normal mutative code 로 사용할 수 있게 해준다.
import { createAction, createReducer } from '@reduxjs/toolkit'
const increment = createAction('counter/increment')
const decrement = createAction('counter/decrement')
const incrementByAmount = createAction('counter/incrementByAmount')
const initialState = { value: 0 }
const counterReducer = createReducer(initialState, (builder) => {
builder
.addCase(increment, (state, action) => {
state.value++
})
.addCase(decrement, (state, action) => {
state.value--
})
.addCase(incrementByAmount, (state, action) => {
state.value += action.payload
})
})
하나의 액션 타입을 다루는 reducer case 를 추가한다.
builder.addMatcher
, builder.addDefaultCase
보다 앞에서 호출되어야 한다.
actionCreator
: plain action type 문자열이거나 createAction 을 통해 생성된 액션 생성자reducer
: 실질적인 reducer 함수action.type
속성만이 아니라 자체적인 함수를 통해 들어오는 액션들을 일치시킬수 있다.
builder.addMatcher
보다 뒤에서, builder.addDefaultCase
보다 앞에서 호출되어야 한다.
matcher
: matcher 함수, TS 에서는 type 을 예측 가능한 함수가 되어야 한다.reducer
: 실질적인 reducer 함수import { createAction, createReducer } from '@reduxjs/toolkit'
const initialState = {}
const resetAction = createAction('reset-tracked-loading-state')
function isPendingAction(action) {
return action.type.endsWith('/pending')
}
const reducer = createReducer(initialState, (builder) => {
builder
.addCase(resetAction, () => initialState)
// matcher can be defined outside as a type predicate function
.addMatcher(isPendingAction, (state, action) => {
state[action.meta.requestId] = 'pending'
})
.addMatcher(
// matcher can be defined inline as a type predicate function
(action) => action.type.endsWith('/rejected'),
(state, action) => {
state[action.meta.requestId] = 'rejected'
}
)
// matcher can just return boolean and the matcher can receive a generic argument
.addMatcher(
(action) => action.type.endsWith('/fulfilled'),
(state, action) => {
state[action.meta.requestId] = 'fulfilled'
}
)
})
만약 아무 리듀서가 주어지지 않거나 이 액션에 일치하는 리듀서가 없을경우에 대한 기본 리듀서를 추가한다.
reducer
: 기본 리듀서 함수import { createReducer } from '@reduxjs/toolkit'
const initialState = { otherActions: 0 }
const reducer = createReducer(initialState, (builder) => {
builder
// .addCase(...)
// .addMatcher(...)
.addDefaultCase((state, action) => {
state.otherActions++
})
})
주어진 액션 타입 문자열에 대해 액션 생성자 함수를 생성한다. 이 함수는 자체적으로 toString() 을 갖고 있어서 type constant 대신 사용할 수 있다.
function createAction(type, prepareAction?)
import { createAction } from '@reduxjs/toolkit'
const increment = createAction('counter/increment')
let action = increment()
// { type: 'counter/increment' }
action = increment(3)
// returns { type: 'counter/increment', payload: 3 }
console.log(increment.toString())
// 'counter/increment'
console.log(`The action type is: ${increment}`)
// 'The action type is: counter/increment'
////
const addTodo = createAction('todos/add', function prepare(text) {
return {
payload: {
text,
id: nanoid(),
createdAt: new Date().toISOString(),
},
}
})
리듀서 함수의 객체, 슬라이스 이름, 초기 상태 값을 받아들이고, 해당 액션 생성자와 액션 유형을 사용하여 슬라이스 리듀서를 자동으로 생성한다.
내부적으로 createAction 와 createReducer 를 사용하기에 이 API 역시 Immer 를 사용하여 mutating 방식으로 immutable update 를 할 수 있다.
import { createSlice } from '@reduxjs/toolkit'
const initialState = { value: 0 }
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment(state) {
state.value++
},
decrement(state) {
state.value--
},
incrementByAmount(state, action) {
state.value += action.payload
},
},
})
export const { increment, decrement, incrementByAmount } = counterSlice.actions
export default counterSlice.reducer
function createSlice({
// A name, used in action types
name: string,
// The initial state for the reducer
initialState: any,
// An object of "case reducers". Key names will be used to generate actions.
reducers: Object<string, ReducerFunction | ReducerAndPrepareObject>
// A "builder callback" function used to add more reducers, or
// an additional object of "case reducers", where the keys should be other
// action types
extraReducers?:
| Object<string, ReducerFunction>
| ((builder: ActionReducerMapBuilder<State>) => void)
})
reducers 를 통해 전달된 객체는 createReducer
로 전달되어 상태를 mutate 하는 것을 안전하게 할 수 있다.
extraReducer 를 사용하는 예시
import { createAction, createSlice } from '@reduxjs/toolkit'
const incrementBy = createAction('incrementBy')
const decrement = createAction('decrement')
function isRejectedAction(action) {
return action.type.endsWith('rejected')
}
createSlice({
name: 'counter',
initialState: 0,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(incrementBy, (state, action) => {
// action is inferred correctly here if using TS
})
// You can chain calls, or have separate `builder.addCase()` lines each time
.addCase(decrement, (state, action) => {})
// You can match a range of action types
.addMatcher(
isRejectedAction,
// `action` will be inferred as a RejectedAction due to isRejectedAction being defined as a type guard
(state, action) => {}
)
// and provide a default case if no other handlers matched
.addDefaultCase((state, action) => {})
},
})
import { createSlice, createAction } from '@reduxjs/toolkit'
import { createStore, combineReducers } from 'redux'
const incrementBy = createAction('incrementBy')
const decrementBy = createAction('decrementBy')
const counter = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state - 1,
multiply: {
reducer: (state, action) => state * action.payload,
prepare: (value) => ({ payload: value || 2 }), // fallback if the payload is a falsy value
},
},
// "builder callback API", recommended for TypeScript users
extraReducers: (builder) => {
builder.addCase(incrementBy, (state, action) => {
return state + action.payload
})
builder.addCase(decrementBy, (state, action) => {
return state - action.payload
})
},
})
const user = createSlice({
name: 'user',
initialState: { name: '', age: 20 },
reducers: {
setUserName: (state, action) => {
state.name = action.payload // mutate the state all you want with immer
},
},
// "map object API"
extraReducers: {
[counter.actions.increment]: (
state,
action /* action will be inferred as "any", as the map notation does not contain type information */
) => {
state.age += 1
},
},
})
const reducer = combineReducers({
counter: counter.reducer,
user: user.reducer,
})
const store = createStore(reducer)
store.dispatch(counter.actions.increment())
// -> { counter: 1, user: {name : '', age: 21} }
store.dispatch(counter.actions.increment())
// -> { counter: 2, user: {name: '', age: 22} }
store.dispatch(counter.actions.multiply(3))
// -> { counter: 6, user: {name: '', age: 22} }
store.dispatch(counter.actions.multiply())
// -> { counter: 12, user: {name: '', age: 22} }
console.log(`${counter.actions.decrement}`)
// -> "counter/decrement"
store.dispatch(user.actions.setUserName('eric'))
// -> { counter: 12, user: { name: 'eric', age: 22} }
액션 타입 문자열과 프로미스를 반환하는 함수를 받고 그 프로미스에 기반하여 pending, fulfilled, rejected
액션 타입을 dispatch 하는 thunk 를 생성한다.
비동기 요청 lifecycle 을 처리하기 위한 표준 권장 접근 방식을 추상화한다.
가져오고 있는 데이터가 무엇인지, 로딩중인 상태를 어떻게 추적할지, 반환된 데이터를 어떻게 처리할 지 모르기 때문에 어떤 리듀서 함수도 생성하지 않는다. 앱에 적합한 로직에 따라 이 액션들 로딩중인 상태들을 다루는 자체적인 리듀서로직을 만들어야 한다.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
// First, create the thunk
const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId: number, thunkAPI) => {
const response = await userAPI.fetchById(userId)
return response.data
}
)
interface UsersState {
entities: []
loading: 'idle' | 'pending' | 'succeeded' | 'failed'
}
const initialState = {
entities: [],
loading: 'idle',
} as UsersState
// Then, handle actions in your reducers:
const usersSlice = createSlice({
name: 'users',
initialState,
reducers: {
// standard reducer logic, with auto-generated action types per reducer
},
extraReducers: (builder) => {
// Add reducers for additional action types here, and handle loading state as needed
builder.addCase(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))
RTK Query data fetching API
는 리덕스 앱을 위한 데이터 가져오기와 캐싱 해결책으로 만들어졌기 때문에 어떤 thunk 나 데이터를 가져오는 것을 관리하기 위한 리듀서를 작성할 필요를 제거해준다.
비동기 요청의 생명주기를 나타내는 추가적인 액션 타입 상수를 생성하는데 사용되는 문자열이다.
만약 type 이 users/requestStatus
라면 이는 다음 3가지 액션 타입을 생성한다.
pending
: users/requestStatus/pending
fulfilled
: users/requestStatus/fulfilled
rejected
: users/requestStatus/rejected
어떤 비동기 로직의 결과를 포함하는 프로미스를 반환해야 하는 콜백 함수이다. 이는 또한 값을 동기적으로 반환할 수도 있다. 만약 오류가 생긴다면 Error 인스턴스나 에러 메세지 등을 포함하는 plain value 를 포함하는 rejected promise 를 반환한다.
payloadCreator
는 2개의 인자를 갖는다.
args
: 단일 값으로 여러개의 값을 전달하고 싶으면 객체로 전달한다.dispatch(fetchUsers({status: 'active', sortBy: 'name'}))
thunkAPI
: Redux thunk 함수로 전달되는 모든 파라미터를 포함하는 객체이다.
전체 사용 예시 코드
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI, User } from './userAPI'
const fetchUserById = createAsyncThunk<User, string, {
state: { users: { loading: string, currentRequestId: string } }
}> (
'users/fetchByIdStatus',
async (userId: string, { getState, requestId }) => {
const { currentRequestId, loading } = getState().users
if (loading !== 'pending' || requestId !== currentRequestId) {
return
}
const response = await userAPI.fetchById(userId)
return response.data
}
)
const usersSlice = createSlice({
name: 'users',
initialState: {
entities: [],
loading: 'idle',
currentRequestId: undefined,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUserById.pending, (state, action) => {
if (state.loading === 'idle') {
state.loading = 'pending'
state.currentRequestId = action.meta.requestId
}
})
.addCase(fetchUserById.fulfilled, (state, action) => {
const { requestId } = action.meta
if (
state.loading === 'pending' &&
state.currentRequestId === requestId
) {
state.loading = 'idle'
state.entities.push(action.payload)
state.currentRequestId = undefined
}
})
.addCase(fetchUserById.rejected, (state, action) => {
const { requestId } = action.meta
if (
state.loading === 'pending' &&
state.currentRequestId === requestId
) {
state.loading = 'idle'
state.error = action.error
state.currentRequestId = undefined
}
})
},
})
const UsersComponent = () => {
const { entities, loading, error } = useSelector((state) => state.users)
const dispatch = useDispatch()
const fetchOneUser = async (userId) => {
try {
const user = await dispatch(fetchUserById(userId)).unwrap()
showToast('success', `Fetched ${user.name}`)
} catch (err) {
showToast('error', `Fetch failed: ${err.message}`)
}
}
// render UI here
}
RTK Query 는 데이터 가져오기 및 캐싱의 사용 사례를 해결하기 위한 목적으로 만들어졌고 앱을 위한 API 인터페이스 레이어를 정의하는 강력한 toolset 이다. RTK Query 를 통해 웹 애플리케이션에서 데이터를 로드하는 일반적인 경우를 단순화하기에 데이터 가져오기 및 캐싱을 직접 작성할 필요가 없다.
import { createApi } from '@reduxjs/toolkit/query'
/* React-specific entry point that automatically generates
hooks corresponding to the defined endpoints */
import { createApi } from '@reduxjs/toolkit/query/react'
RTK Query 기능의 핵심이다. 종단점 집합을 정의할 수 있고 일련의 종단점으로부터 어떻게 데이터를 가져오고 변형시킬 수 있는지를 포함하여 어떻게 데이터를 가져오는지를 설명한다. 대부분의 경우 base URL 당 하나의 API slice 를 사용한다.
// Need to use the React-specific entry point to allow generating React hooks
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
// Define a service using a base URL and expected endpoints
export const pokemonApi = createApi({
reducerPath: 'pokemonApi',
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: (builder) => ({
getPokemonByName: builder.query({
query: (name) => `pokemon/${name}`,
}),
}),
})
// Export hooks for usage in function components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = pokemonApi
만약 queryFn 옵션이 명시되지 않았다면 각각의 종단점에서 사용된다. RTK Query 는 fetchBaseQuery 라는 경량화된 fetch 래퍼를 제공한다.
서버에 수행하고 싶은 작업 집합이다. builder 문법을 사용하여 객체를 정의한다. query 와 mutation 이라는 두가지 타입의 endpoint 가 있다.
query
: 선택한 데이터 가져오기 라이브러리를 사용하여 쿼리 작업을 수행할 수 있지만 일반적인 권장사항은 데이터를 검색하는 요청에만 쿼리를 사용하는 것이다. 서버의 데이터를 변경하거나 캐시를 무효화할 수 있는 모든 경우에는 mutation 을 사용해야 한다.mutation
: 데이터 업데이트를 서버에 보내고 변경 사항을 로컬 캐시에 적용하는 데 사용된다.추가 예시
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/' }),
tagTypes: ['Posts'],
endpoints: (build) => ({
getPosts: build.query({
query: () => 'posts',
providesTags: (result) =>
result ? result.map(({ id }) => ({ type: 'Posts', id })) : [],
}),
addPost: build.mutation({
query: (body) => ({
url: `posts`,
method: 'POST',
body,
}),
invalidatesTags: ['Posts'],
}),
}),
})
// Auto-generated hooks
export const { useGetPostsQuery, useAddPostMutation } = api
// Possible exports
export const { endpoints, reducerPath, reducer, middleware } = api
// reducerPath, reducer, middleware are only used in store configuration
// endpoints will have:
// endpoints.getPosts.initiate(), endpoints.getPosts.select(), endpoints.getPosts.useQuery()
// endpoints.addPost.initiate(), endpoints.addPost.select(), endpoints.addPost.useMutation()
// see `createApi` overview for _all exports_
요청을 단순화하는 것을 목표로 하는 작은 wrapper 이다. HTTP 요청을 단순화하기 위해 사용한다. axios, superagent 등을 완전히 대체하는 것은 아니나 대부분의 HTTP 요청 요구사항을 충족한다.
// Or from '@reduxjs/toolkit/query/react'
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query'
export const pokemonApi = createApi({
// Set the baseUrl for every endpoint below
baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
endpoints: (builder) => ({
getPokemonByName: builder.query({
// Will make a request like https://pokeapi.co/api/v2/pokemon/bulbasaur
query: (name) => `pokemon/${name}`,
}),
updatePokemon: builder.mutation({
query: ({ name, patch }) => ({
url: `pokemon/${name}`,
// When performing a mutation, you typically use a method of
// PATCH/PUT/POST/DELETE for REST endpoints
method: 'PATCH',
// fetchBaseQuery automatically adds `content-type: application/json` to
// the Headers and calls `JSON.stringify(patch)`
body: patch,
}),
}),
}),
})
type prepareHeaders = (
headers: Headers,
api: {
getState: () => unknown
extra: unknown
endpoint: string
type: 'query' | 'mutation'
forced: boolean | undefined
}
) => Headers | void
import { fetchBaseQuery } from '@reduxjs/toolkit/query'
const baseQuery = fetchBaseQuery({
baseUrl: '/',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
// If we have a token set in state, let's assume that we should be passing it.
if (token) {
headers.set('authorization', `Bearer ${token}`)
}
return headers
},
})
store, action 등을 따로 관리할 필요가 없으니 redux 를 사용시에는 어지간하면 redux toolkit 의 createSlice
를 사용할 것 같다. HTTP 요청도 같이 처리할 테니 redux 에서 createSlice, createAsyncThunk 만 잘 활용한다면 어지간한 기능은 다 잘 쓸 수 있을 것 같고 추가로 RTK Query 를 활용해보자.